Files
wehub-resource-sync db620d33df
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:25 +08:00

2053 lines
84 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="ToolApprovalAgent"/> class.
/// </summary>
public class ToolApprovalAgentTests
{
#region Constructor
/// <summary>
/// Verify that constructor throws ArgumentNullException when innerAgent is null.
/// </summary>
[Fact]
public void Constructor_NullInnerAgent_ThrowsAsync()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("innerAgent", () => new ToolApprovalAgent(null!));
}
/// <summary>
/// Verify that constructor creates a valid instance.
/// </summary>
[Fact]
public void Constructor_ValidInnerAgent_CreatesInstanceAsync()
{
// Arrange
var innerAgent = new Mock<AIAgent>().Object;
// Act
var agent = new ToolApprovalAgent(innerAgent);
// Assert
Assert.NotNull(agent);
}
/// <summary>
/// Verify that constructor accepts custom options.
/// </summary>
[Fact]
public void Constructor_CustomOptions_CreatesInstance()
{
// Arrange
var innerAgent = new Mock<AIAgent>().Object;
var options = new ToolApprovalAgentOptions { JsonSerializerOptions = new JsonSerializerOptions() };
// Act
var agent = new ToolApprovalAgent(innerAgent, options);
// Assert
Assert.NotNull(agent);
}
#endregion
#region RunAsync - Passthrough
/// <summary>
/// Verify that when there are no approval requests, response passes through unchanged.
/// </summary>
[Fact]
public async Task RunAsync_NoApprovalRequests_PassesThroughAsync()
{
// Arrange
var expectedResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Hello")]);
var innerAgent = CreateMockAgent(expectedResponse);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Act
var response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, "Hi")],
new ChatClientAgentSession());
// Assert
Assert.Equal("Hello", response.Text);
}
/// <summary>
/// Verify that approval requests with no matching rules are surfaced to the caller.
/// </summary>
[Fact]
public async Task RunAsync_ApprovalRequestNoRule_SurfacesToCallerAsync()
{
// Arrange
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool"));
var responseMessage = new ChatMessage(ChatRole.Assistant, [approvalRequest]);
var expectedResponse = new AgentResponse([responseMessage]);
var innerAgent = CreateMockAgent(expectedResponse);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Act
var response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, "Hi")],
new ChatClientAgentSession());
// Assert
var requests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(requests);
Assert.Equal("MyTool", ((FunctionCallContent)requests[0].ToolCall).Name);
}
#endregion
#region RunAsync - Deferred Auto-Approve
/// <summary>
/// Verify that when a tool-level rule exists, matching approval requests are
/// auto-approved immediately and the inner agent is re-called in the same run.
/// </summary>
[Fact]
public async Task RunAsync_ToolLevelRule_DeferredAutoApproveAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool"));
// Call 1: establish rule + inner agent returns approval request → auto-approved → re-call inner agent
var approvalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]);
var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
var callCount = 0;
List<ChatMessage>? secondCallMessages = null;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) =>
{
callCount++;
if (callCount == 2)
{
secondCallMessages = msgs.ToList();
}
})
.ReturnsAsync(() => callCount == 1 ? approvalResponse : finalResponse);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Call 1: send always-approve → establishes rule, inner returns TARc → auto-approved → re-calls inner
var alwaysApproveResponse = approvalRequest.CreateAlwaysApproveToolResponse("User said always");
var response1 = await agent.RunAsync(
[new ChatMessage(ChatRole.User, [alwaysApproveResponse])],
session);
// Assert — inner agent was called twice within the same RunAsync call
Assert.Equal(2, callCount);
Assert.Equal("Done", response1.Text);
// Response should NOT surface the auto-approved approval request to caller
var surfacedRequests = response1.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
Assert.Empty(surfacedRequests);
// Verify that the re-call received the injected auto-approval response
Assert.NotNull(secondCallMessages);
var injectedApprovals = secondCallMessages!.SelectMany(m => m.Contents).OfType<ToolApprovalResponseContent>().ToList();
Assert.Single(injectedApprovals);
Assert.True(injectedApprovals[0].Approved);
}
/// <summary>
/// Verify that a tool+arguments rule stores pending auto-approvals for matching calls.
/// </summary>
[Fact]
public async Task RunAsync_ToolWithArgsRule_DeferredAutoApproveAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var args = new Dictionary<string, object?> { ["path"] = "test.txt" };
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadFile", args));
var approvalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]);
var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "File content")]);
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
callCount++;
return callCount == 1 ? approvalResponse : finalResponse;
});
var agent = new ToolApprovalAgent(innerAgent.Object);
// Call 1: set up the rule
var alwaysApproveResponse = approvalRequest.CreateAlwaysApproveToolWithArgumentsResponse();
await agent.RunAsync(
[new ChatMessage(ChatRole.User, [alwaysApproveResponse])],
session);
// Call 2: pending auto-approval injected
var response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, "Continue")],
session);
// Assert
Assert.Equal("File content", response.Text);
}
/// <summary>
/// Verify that a tool+arguments rule does NOT auto-approve when arguments differ.
/// </summary>
[Fact]
public async Task RunAsync_ToolWithArgsRule_DoesNotAutoApproveDifferentArgsAsync()
{
// Arrange
var session = new ChatClientAgentSession();
// Set up rule with args { path: "test.txt" }
var ruleArgs = new Dictionary<string, object?> { ["path"] = "test.txt" };
var ruleRequest = new ToolApprovalRequestContent("req0", new FunctionCallContent("call0", "ReadFile", ruleArgs));
var alwaysApproveResponse = ruleRequest.CreateAlwaysApproveToolWithArgumentsResponse();
// Then the inner agent returns an approval for DIFFERENT args
var differentArgs = new Dictionary<string, object?> { ["path"] = "other.txt" };
var newApprovalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadFile", differentArgs));
var approvalResponseMsg = new AgentResponse([new ChatMessage(ChatRole.Assistant, [newApprovalRequest])]);
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(approvalResponseMsg);
var agent = new ToolApprovalAgent(innerAgent.Object);
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, [alwaysApproveResponse]),
};
// Act
var response = await agent.RunAsync(inputMessages, session);
// Assert — the approval request should surface to the caller (not auto-approved)
var requests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(requests);
Assert.Equal("ReadFile", ((FunctionCallContent)requests[0].ToolCall).Name);
}
/// <summary>
/// Verify that approving a no-argument call with the "always approve with exact arguments"
/// option does NOT auto-approve a later same-tool call that supplies arguments. The later
/// call must still surface for explicit approval (security regression for #6486).
/// </summary>
[Fact]
public async Task RunAsync_EmptyArgsToolWithArgsRule_DoesNotAutoApproveCallWithArgumentsAsync()
{
// Arrange
var session = new ChatClientAgentSession();
// Approve a no-argument SendPayment call with "always approve with exact arguments".
var ruleRequest = new ToolApprovalRequestContent("req0", new FunctionCallContent("call0", "SendPayment"));
var alwaysApproveResponse = ruleRequest.CreateAlwaysApproveToolWithArgumentsResponse();
// The inner agent then requests SendPayment WITH sensitive arguments.
var sensitiveArgs = new Dictionary<string, object?>
{
["recipient"] = "attacker@example.test",
["amount"] = 5000,
};
var newApprovalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "SendPayment", sensitiveArgs));
var approvalResponseMsg = new AgentResponse([new ChatMessage(ChatRole.Assistant, [newApprovalRequest])]);
var innerAgent = CreateMockAgent(approvalResponseMsg);
var agent = new ToolApprovalAgent(innerAgent.Object);
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, [alwaysApproveResponse]),
};
// Act
var response = await agent.RunAsync(inputMessages, session);
// Assert — the argument-bearing request must surface, not be auto-approved.
var requests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(requests);
Assert.Equal("SendPayment", ((FunctionCallContent)requests[0].ToolCall).Name);
}
/// <summary>
/// Verify that approving a no-argument call with the "always approve with exact arguments"
/// option still auto-approves a later same-tool call that also supplies no arguments,
/// preserving the intended narrow behavior.
/// </summary>
[Fact]
public async Task RunAsync_EmptyArgsToolWithArgsRule_AutoApprovesLaterEmptyArgsCallAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var ruleRequest = new ToolApprovalRequestContent("req0", new FunctionCallContent("call0", "SendPayment"));
var alwaysApproveResponse = ruleRequest.CreateAlwaysApproveToolWithArgumentsResponse();
// Inner agent first re-requests SendPayment with no arguments, then returns a final response.
var emptyArgsRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "SendPayment"));
var approvalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [emptyArgsRequest])]);
var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Payment sent")]);
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
callCount++;
return callCount == 1 ? approvalResponse : finalResponse;
});
var agent = new ToolApprovalAgent(innerAgent.Object);
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, [alwaysApproveResponse]),
};
// Act
var response = await agent.RunAsync(inputMessages, session);
// Assert — the no-argument call is auto-approved and the inner agent reaches its final response.
Assert.Equal("Payment sent", response.Text);
var requests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
Assert.Empty(requests);
}
#endregion
#region Mixed Auto-Approve
/// <summary>
/// Verify that when some approval requests match rules and others don't,
/// matching ones are stored as pending and non-matching are surfaced.
/// </summary>
[Fact]
public async Task RunAsync_MixedApprovalRequests_SurfacesNonMatchingStoreMatchingAsync()
{
// Arrange
var session = new ChatClientAgentSession();
// Set up a rule for ToolA only
var ruleRequest = new ToolApprovalRequestContent("rule-req", new FunctionCallContent("rule-call", "ToolA"));
var alwaysApprove = ruleRequest.CreateAlwaysApproveToolResponse();
// Inner agent returns approval requests for both ToolA and ToolB
var approvalA = new ToolApprovalRequestContent("reqA", new FunctionCallContent("callA", "ToolA"));
var approvalB = new ToolApprovalRequestContent("reqB", new FunctionCallContent("callB", "ToolB"));
var mixedResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalA, approvalB])]);
var innerAgent = CreateMockAgent(mixedResponse);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Call 1: establish rule + get mixed approval response
var response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, [alwaysApprove])],
session);
// Assert — ToolB request surfaced to caller, ToolA auto-approved is removed from response
var surfacedRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(surfacedRequests);
Assert.Equal("ToolB", ((FunctionCallContent)surfacedRequests[0].ToolCall).Name);
// Call 2: verify pending auto-approval for ToolA is injected
List<ChatMessage>? capturedMessages = null;
var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Final")]);
var callCount = 0;
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) =>
{
callCount++;
capturedMessages = msgs.ToList();
})
.ReturnsAsync(finalResponse);
// User manually approves ToolB and sends along
var toolBApproval = approvalB.CreateResponse(approved: true);
await agent.RunAsync(
[new ChatMessage(ChatRole.User, [toolBApproval])],
session);
// Verify pending auto-approval for ToolA was injected
Assert.NotNull(capturedMessages);
var allApprovals = capturedMessages!.SelectMany(m => m.Contents).OfType<ToolApprovalResponseContent>().ToList();
Assert.Equal(2, allApprovals.Count); // ToolA auto-approval + ToolB manual approval
}
#endregion
#region Content Ordering
/// <summary>
/// Verify that content ordering is preserved when unwrapping AlwaysApproveToolApprovalResponseContent.
/// </summary>
[Fact]
public async Task RunAsync_UnwrapPreservesContentOrderAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool"));
var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse();
// Message with mixed content: text before, always-approve in middle, text after
var textBefore = new TextContent("Before");
var textAfter = new TextContent("After");
var inputMessage = new ChatMessage(ChatRole.User, [textBefore, alwaysApprove, textAfter]);
List<ChatMessage>? capturedMessages = null;
var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]);
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) =>
capturedMessages = msgs.ToList())
.ReturnsAsync(finalResponse);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Act
await agent.RunAsync([inputMessage], session);
// Assert — content order should be: TextContent("Before"), ToolApprovalResponseContent, TextContent("After")
Assert.NotNull(capturedMessages);
var contents = capturedMessages![0].Contents;
Assert.Equal(3, contents.Count);
Assert.IsType<TextContent>(contents[0]);
Assert.Equal("Before", ((TextContent)contents[0]).Text);
Assert.IsType<ToolApprovalResponseContent>(contents[1]);
Assert.IsType<TextContent>(contents[2]);
Assert.Equal("After", ((TextContent)contents[2]).Text);
}
#endregion
#region Unwrapping
/// <summary>
/// Verify that AlwaysApproveToolApprovalResponseContent is unwrapped to the inner ToolApprovalResponseContent
/// before being forwarded to the inner agent.
/// </summary>
[Fact]
public async Task RunAsync_UnwrapsAlwaysApproveResponse_ForwardsInnerResponseAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool"));
var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse();
List<ChatMessage>? capturedMessages = null;
var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]);
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) =>
capturedMessages = msgs.ToList())
.ReturnsAsync(finalResponse);
var agent = new ToolApprovalAgent(innerAgent.Object);
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, [alwaysApprove]),
};
// Act
await agent.RunAsync(inputMessages, session);
// Assert — the forwarded message should contain ToolApprovalResponseContent, not AlwaysApproveToolApprovalResponseContent
Assert.NotNull(capturedMessages);
var contents = capturedMessages!.SelectMany(m => m.Contents).ToList();
Assert.DoesNotContain(contents, c => c is AlwaysApproveToolApprovalResponseContent);
Assert.Contains(contents, c => c is ToolApprovalResponseContent);
}
#endregion
#region Rule Persistence
/// <summary>
/// Verify that rules persist across multiple RunAsync calls on the same session,
/// and that auto-approved TARc are immediately handled via re-call within the same run.
/// </summary>
[Fact]
public async Task RunAsync_RulesPersistAcrossCallsAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool"));
var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse();
// Call 1: no approval requests in response, just establish the rule
var firstResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]);
// Call 2a: return an approval request that should match stored rule → auto-approved → re-call
var secondApproval = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "MyTool"));
var secondResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [secondApproval])]);
// Call 2b: re-call after auto-approve, final response
var thirdResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done after auto-approve")]);
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
callCount++;
return callCount switch
{
1 => firstResponse,
2 => secondResponse,
_ => thirdResponse,
};
});
var agent = new ToolApprovalAgent(innerAgent.Object);
// Call 1: establish rule (no approval requests returned)
await agent.RunAsync(
[new ChatMessage(ChatRole.User, [alwaysApprove])],
session);
// Call 2: inner agent returns TARc → matches rule → auto-approved → re-call → final response
var response2 = await agent.RunAsync(
[new ChatMessage(ChatRole.User, "Do something")],
session);
// Assert — inner agent called 3 times total (1 for rule setup, 2 for auto-approve loop)
Assert.Equal("Done after auto-approve", response2.Text);
Assert.Equal(3, callCount);
}
/// <summary>
/// Verify that collected approval responses are cleared after injection (during the loop re-call).
/// A subsequent RunAsync call should not inject them again.
/// </summary>
[Fact]
public async Task RunAsync_CollectedApprovalResponses_ClearedAfterInjectionAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool"));
var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse();
// Call 1: establish rule
var firstResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]);
// Call 2a: return approval → auto-approved → loop
var approval = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "MyTool"));
var secondResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [approval])]);
// Call 2b: re-call after auto-approve (injected)
var thirdResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Injected")]);
// Call 3: no pending (already cleared by the loop)
var fourthResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Clean")]);
var callCount = 0;
List<ChatMessage>? lastCallMessages = null;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) =>
{
callCount++;
lastCallMessages = msgs.ToList();
})
.ReturnsAsync(() => callCount switch
{
1 => firstResponse,
2 => secondResponse,
3 => thirdResponse,
_ => fourthResponse,
});
var agent = new ToolApprovalAgent(innerAgent.Object);
// Call 1: establish rule (callCount → 1)
await agent.RunAsync([new ChatMessage(ChatRole.User, [alwaysApprove])], session);
// Call 2: inner returns TARc → auto-approved → loop → callCount → 2, 3
await agent.RunAsync([new ChatMessage(ChatRole.User, "Trigger approval")], session);
// Call 3: no pending (cleared by the loop), callCount → 4
var response3 = await agent.RunAsync([new ChatMessage(ChatRole.User, "No pending")], session);
// Assert — last call should only have the user message, no injected approvals
Assert.NotNull(lastCallMessages);
var approvals = lastCallMessages!.SelectMany(m => m.Contents).OfType<ToolApprovalResponseContent>().ToList();
Assert.Empty(approvals);
Assert.Equal("Clean", response3.Text);
}
#endregion
#region RunStreamingAsync
/// <summary>
/// Verify that streaming passthrough works when there are no approval requests.
/// </summary>
[Fact]
public async Task RunStreamingAsync_NoApprovalRequests_PassesThroughAsync()
{
// Arrange
var updates = new[] { new AgentResponseUpdate(ChatRole.Assistant, "Hello") };
var innerAgent = CreateMockStreamingAgent(updates);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Act
var results = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(
[new ChatMessage(ChatRole.User, "Hi")],
new ChatClientAgentSession()))
{
results.Add(update);
}
// Assert
Assert.Single(results);
Assert.Equal("Hello", results[0].Text);
}
#endregion
#region MatchesRule
/// <summary>
/// Verify that a tool-level rule matches regardless of arguments.
/// </summary>
[Fact]
public void MatchesRule_ToolLevelRule_MatchesAnyArgs()
{
// Arrange
var rules = new List<ToolApprovalRule>
{
new() { ToolName = "MyTool" },
};
var request = new ToolApprovalRequestContent("req1",
new FunctionCallContent("call1", "MyTool", new Dictionary<string, object?> { ["x"] = "1" }));
// Act & Assert
Assert.True(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions));
}
/// <summary>
/// Verify that a tool-level rule does not match a different tool name.
/// </summary>
[Fact]
public void MatchesRule_ToolLevelRule_DoesNotMatchDifferentTool()
{
// Arrange
var rules = new List<ToolApprovalRule>
{
new() { ToolName = "ToolA" },
};
var request = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolB"));
// Act & Assert
Assert.False(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions));
}
/// <summary>
/// Verify that a tool+arguments rule matches with exact same arguments.
/// </summary>
[Fact]
public void MatchesRule_ToolWithArgsRule_MatchesExactArgs()
{
// Arrange — rule arguments must be in serialized form (JSON string representation)
var rules = new List<ToolApprovalRule>
{
new()
{
ToolName = "ReadFile",
Arguments = new Dictionary<string, string> { ["path"] = "\"test.txt\"" },
},
};
var request = new ToolApprovalRequestContent("req1",
new FunctionCallContent("call1", "ReadFile", new Dictionary<string, object?> { ["path"] = "test.txt" }));
// Act & Assert
Assert.True(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions));
}
/// <summary>
/// Verify that a tool+arguments rule does not match when argument values differ.
/// </summary>
[Fact]
public void MatchesRule_ToolWithArgsRule_DoesNotMatchDifferentValues()
{
// Arrange
var rules = new List<ToolApprovalRule>
{
new()
{
ToolName = "ReadFile",
Arguments = new Dictionary<string, string> { ["path"] = "\"test.txt\"" },
},
};
var request = new ToolApprovalRequestContent("req1",
new FunctionCallContent("call1", "ReadFile", new Dictionary<string, object?> { ["path"] = "other.txt" }));
// Act & Assert
Assert.False(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions));
}
/// <summary>
/// Verify that a tool+arguments rule does not match when argument count differs.
/// </summary>
[Fact]
public void MatchesRule_ToolWithArgsRule_DoesNotMatchDifferentArgCount()
{
// Arrange
var rules = new List<ToolApprovalRule>
{
new()
{
ToolName = "ReadFile",
Arguments = new Dictionary<string, string> { ["path"] = "\"test.txt\"" },
},
};
var request = new ToolApprovalRequestContent("req1",
new FunctionCallContent("call1", "ReadFile", new Dictionary<string, object?>
{
["path"] = "test.txt",
["encoding"] = "utf-8",
}));
// Act & Assert
Assert.False(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions));
}
/// <summary>
/// Verify that a non-FunctionCallContent tool call does not match any rule.
/// </summary>
[Fact]
public void MatchesRule_NonFunctionCallContent_ReturnsFalse()
{
// Arrange
var rules = new List<ToolApprovalRule>
{
new() { ToolName = "MyTool" },
};
var request = new ToolApprovalRequestContent("req1", new ToolCallContent("call1"));
// Act & Assert
Assert.False(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions));
}
/// <summary>
/// Verify that matching works with JsonElement argument values.
/// </summary>
[Fact]
public void MatchesRule_JsonElementArgs_MatchesCorrectly()
{
// Arrange
var rules = new List<ToolApprovalRule>
{
new()
{
ToolName = "MyTool",
Arguments = new Dictionary<string, string> { ["count"] = "42" },
},
};
var jsonElement = JsonDocument.Parse("42").RootElement;
var request = new ToolApprovalRequestContent("req1",
new FunctionCallContent("call1", "MyTool", new Dictionary<string, object?> { ["count"] = jsonElement }));
// Act & Assert
Assert.True(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions));
}
/// <summary>
/// Verify that an empty-arguments rule (non-null but empty) matches only a call that
/// supplies no arguments, and never widens into a tool-level match.
/// </summary>
[Fact]
public void MatchesRule_EmptyArgumentsRule_MatchesEmptyArgumentCall_ReturnsTrue()
{
// Arrange — an exact-arguments approval of a no-argument call is stored as an empty dictionary.
var rules = new List<ToolApprovalRule>
{
new()
{
ToolName = "SendPayment",
Arguments = new Dictionary<string, string>(),
},
};
var request = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "SendPayment"));
// Act & Assert
Assert.True(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions));
}
/// <summary>
/// Verify that an empty-arguments rule does NOT match a later same-tool call that supplies
/// arguments. This guards against an exact-arguments approval of a no-argument call being
/// widened into a tool-level approval.
/// </summary>
[Fact]
public void MatchesRule_EmptyArgumentsRule_DoesNotMatchCallWithArguments_ReturnsFalse()
{
// Arrange
var rules = new List<ToolApprovalRule>
{
new()
{
ToolName = "SendPayment",
Arguments = new Dictionary<string, string>(),
},
};
var request = new ToolApprovalRequestContent("req1",
new FunctionCallContent("call1", "SendPayment", new Dictionary<string, object?>
{
["recipient"] = "attacker@example.test",
["amount"] = 5000,
}));
// Act & Assert
Assert.False(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions));
}
#endregion
#region Extension Methods
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse creates the correct wrapper.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_SetsCorrectFlags()
{
// Arrange
var request = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool"));
// Act
var result = request.CreateAlwaysApproveToolResponse("Test reason");
// Assert
Assert.True(result.AlwaysApproveTool);
Assert.False(result.AlwaysApproveToolWithArguments);
Assert.True(result.InnerResponse.Approved);
Assert.Equal("Test reason", result.InnerResponse.Reason);
Assert.Equal("req1", result.InnerResponse.RequestId);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolWithArgumentsResponse creates the correct wrapper.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolWithArgumentsResponse_SetsCorrectFlags()
{
// Arrange
var request = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool"));
// Act
var result = request.CreateAlwaysApproveToolWithArgumentsResponse();
// Assert
Assert.False(result.AlwaysApproveTool);
Assert.True(result.AlwaysApproveToolWithArguments);
Assert.True(result.InnerResponse.Approved);
}
/// <summary>
/// Verify that extension methods throw on null request.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_NullRequest_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("request",
() => ((ToolApprovalRequestContent)null!).CreateAlwaysApproveToolResponse());
}
/// <summary>
/// Verify that extension methods throw on null request.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolWithArgumentsResponse_NullRequest_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("request",
() => ((ToolApprovalRequestContent)null!).CreateAlwaysApproveToolWithArgumentsResponse());
}
#endregion
#region Duplicate Rule Prevention
/// <summary>
/// Verify that sending the same always-approve response twice does not create duplicate rules.
/// </summary>
[Fact]
public async Task RunAsync_DuplicateAlwaysApprove_DoesNotDuplicateRuleAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var request1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool"));
var request2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "MyTool"));
var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]);
var innerAgent = CreateMockAgent(finalResponse);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Act — send two always-approve responses for the same tool
await agent.RunAsync(
[new ChatMessage(ChatRole.User, [request1.CreateAlwaysApproveToolResponse()])],
session);
await agent.RunAsync(
[new ChatMessage(ChatRole.User, [request2.CreateAlwaysApproveToolResponse()])],
session);
// Assert — verify the state works correctly (rule still matches on subsequent call)
var thirdApproval = new ToolApprovalRequestContent("req3", new FunctionCallContent("call3", "MyTool"));
var approvalResponseMsg = new AgentResponse([new ChatMessage(ChatRole.Assistant, [thirdApproval])]);
var afterAutoResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Auto-approved")]);
var callCount = 0;
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
callCount++;
return callCount == 1 ? approvalResponseMsg : afterAutoResponse;
});
// Call 3: triggers approval → stored as pending
await agent.RunAsync([new ChatMessage(ChatRole.User, "test")], session);
// Call 4: pending injected
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "continue")], session);
Assert.Equal("Auto-approved", response.Text);
}
#endregion
#region Auto-Approved Removal
/// <summary>
/// Verify that auto-approved requests are removed from the non-streaming response
/// and the inner agent is re-called, returning actual content.
/// </summary>
[Fact]
public async Task RunAsync_AutoApprovedRequest_RemovedFromResponseAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool"));
var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse();
// Inner agent returns a TARc on first call, then real content on second
var approvalResponseContent = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "MyTool"));
var firstResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalResponseContent])]);
var secondResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Tool executed successfully")]);
var innerAgent = new Mock<AIAgent>();
var callCount = 0;
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() => ++callCount == 1 ? firstResponse : secondResponse);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Act: establish rule + inner returns matching approval request → auto-approved → re-call
var response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, [alwaysApprove])],
session);
// Assert — auto-approved request removed, inner agent called twice, final response has text
var allRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
Assert.Empty(allRequests);
Assert.Equal("Tool executed successfully", response.Messages[0].Text);
Assert.Equal(2, callCount);
}
/// <summary>
/// Verify that auto-approved requests are removed from streaming updates
/// and the inner agent is re-called, yielding actual content.
/// </summary>
[Fact]
public async Task RunStreamingAsync_AutoApprovedRequest_RemovedFromUpdatesAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool"));
// First establish a rule via non-streaming
var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse();
var setupResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]);
var innerAgent = new Mock<AIAgent>();
// Setup non-streaming for rule establishment
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(setupResponse);
// First streaming call: text + auto-approvable TARc. Second call: just text (tool executed).
var streamApproval = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "MyTool"));
var textUpdate = new AgentResponseUpdate(ChatRole.Assistant, "Hello");
var approvalUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List<AIContent> { streamApproval });
var finalUpdate = new AgentResponseUpdate(ChatRole.Assistant, "Done");
var streamCallCount = 0;
innerAgent
.Protected()
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(() => ++streamCallCount == 1
? ToAsyncEnumerableAsync(new[] { textUpdate, approvalUpdate })
: ToAsyncEnumerableAsync(new[] { finalUpdate }));
var agent = new ToolApprovalAgent(innerAgent.Object);
// Establish the rule
await agent.RunAsync(
[new ChatMessage(ChatRole.User, [alwaysApprove])],
session);
// Act — stream with auto-approvable request
var results = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(
[new ChatMessage(ChatRole.User, "Do something")],
session))
{
results.Add(update);
}
// Assert — text from first call + final text from re-call; no TARc surfaced
Assert.Equal(2, results.Count);
Assert.Equal("Hello", results[0].Text);
Assert.Equal("Done", results[1].Text);
Assert.Equal(2, streamCallCount);
}
/// <summary>
/// Verify that non-matching approval requests remain in streaming updates.
/// </summary>
[Fact]
public async Task RunStreamingAsync_NonMatchingRequest_SurfacedToCallerAsync()
{
// Arrange
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "UnknownTool"));
var approvalUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List<AIContent> { approvalRequest });
var innerAgent = CreateMockStreamingAgent([approvalUpdate]);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Act
var results = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(
[new ChatMessage(ChatRole.User, "Hi")],
new ChatClientAgentSession()))
{
results.Add(update);
}
// Assert — non-matching request should be surfaced
Assert.Single(results);
var requests = results[0].Contents.OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(requests);
Assert.Equal("UnknownTool", ((FunctionCallContent)requests[0].ToolCall).Name);
}
/// <summary>
/// Verify that a message with only auto-approved content is removed from the response
/// when the inner agent is re-called after auto-approving.
/// </summary>
[Fact]
public async Task RunAsync_MessageWithOnlyAutoApprovedContent_RemovedEntirelyAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var ruleRequest = new ToolApprovalRequestContent("rule-req", new FunctionCallContent("rule-call", "MyTool"));
var alwaysApprove = ruleRequest.CreateAlwaysApproveToolResponse();
// First call: returns TARc-only message + text message. Second call: returns just text (tools executed).
var approvalContent = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool"));
var approvalMessage = new ChatMessage(ChatRole.Assistant, [approvalContent]);
var textMessage = new ChatMessage(ChatRole.Assistant, "Final text");
var firstResponse = new AgentResponse([approvalMessage, textMessage]);
var secondResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Tools executed")]);
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() => ++callCount == 1 ? firstResponse : secondResponse);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Act
var response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, [alwaysApprove])],
session);
// Assert — inner agent re-called after auto-approve, final response from second call
Assert.Equal(2, callCount);
Assert.Single(response.Messages);
Assert.Equal("Tools executed", response.Messages[0].Text);
}
/// <summary>
/// Verify that a streaming update with mixed content (auto-approved + non-auto-approved) only removes the auto-approved part.
/// </summary>
[Fact]
public async Task RunStreamingAsync_MixedUpdate_OnlyRemovesAutoApprovedAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var ruleRequest = new ToolApprovalRequestContent("rule-req", new FunctionCallContent("rule-call", "ToolA"));
var alwaysApprove = ruleRequest.CreateAlwaysApproveToolResponse();
// Setup non-streaming for rule establishment
var setupResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]);
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(setupResponse);
// Streaming update with both auto-approvable (ToolA) and non-auto-approvable (ToolB) requests
var autoApprovable = new ToolApprovalRequestContent("reqA", new FunctionCallContent("callA", "ToolA"));
var notAutoApprovable = new ToolApprovalRequestContent("reqB", new FunctionCallContent("callB", "ToolB"));
var mixedUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List<AIContent> { autoApprovable, notAutoApprovable });
innerAgent
.Protected()
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(ToAsyncEnumerableAsync(new[] { mixedUpdate }));
var agent = new ToolApprovalAgent(innerAgent.Object);
// Establish rule
await agent.RunAsync(
[new ChatMessage(ChatRole.User, [alwaysApprove])],
session);
// Act
var results = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(
[new ChatMessage(ChatRole.User, "Do something")],
session))
{
results.Add(update);
}
// Assert — only ToolB request should remain
Assert.Single(results);
var requests = results[0].Contents.OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(requests);
Assert.Equal("ToolB", ((FunctionCallContent)requests[0].ToolCall).Name);
}
#endregion
#region Queue Behavior - Non-Streaming
/// <summary>
/// Verify that when the inner agent returns multiple unapproved TARc, only the first is returned
/// and the rest are queued.
/// </summary>
[Fact]
public async Task RunAsync_MultipleTARc_ReturnsOnlyFirstAsync()
{
// Arrange
var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA"));
var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB"));
var tarc3 = new ToolApprovalRequestContent("req3", new FunctionCallContent("call3", "ToolC"));
var innerResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc1, tarc2, tarc3])]);
var innerAgent = CreateMockAgent(innerResponse);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Act
var response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, "Do something")],
new ChatClientAgentSession());
// Assert — only the first TARc should be returned
var requests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(requests);
Assert.Equal("ToolA", ((FunctionCallContent)requests[0].ToolCall).Name);
}
/// <summary>
/// Verify that after approving the first queued item, the second is returned on the next call
/// without calling the inner agent.
/// </summary>
[Fact]
public async Task RunAsync_ApproveFirst_ReturnsSecondFromQueueAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA"));
var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB"));
var tarc3 = new ToolApprovalRequestContent("req3", new FunctionCallContent("call3", "ToolC"));
var innerResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc1, tarc2, tarc3])]);
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>((_, _, _, _) => callCount++)
.ReturnsAsync(innerResponse);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Call 1: get first TARc
var response1 = await agent.RunAsync(
[new ChatMessage(ChatRole.User, "Do something")],
session);
// Call 2: approve first, get second from queue
var approval1 = tarc1.CreateResponse(approved: true, reason: "OK");
var response2 = await agent.RunAsync(
[new ChatMessage(ChatRole.User, [approval1])],
session);
// Assert — second TARc returned, inner agent called only once
Assert.Equal(1, callCount);
var requests = response2.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(requests);
Assert.Equal("ToolB", ((FunctionCallContent)requests[0].ToolCall).Name);
}
/// <summary>
/// Verify that "always approve" on the first queued item auto-approves matching items in the queue.
/// </summary>
[Fact]
public async Task RunAsync_AlwaysApproveFirst_AutoApprovesMatchingInQueueAsync()
{
// Arrange
var session = new ChatClientAgentSession();
// Three TARc: two for ToolA (different args), one for ToolB
var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA", arguments: new Dictionary<string, object?> { ["q"] = "query1" }));
var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolA", arguments: new Dictionary<string, object?> { ["q"] = "query2" }));
var tarc3 = new ToolApprovalRequestContent("req3", new FunctionCallContent("call3", "ToolB"));
var innerResponse1 = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc1, tarc2, tarc3])]);
var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>((_, _, _, _) => callCount++)
.ReturnsAsync(() => callCount == 1 ? innerResponse1 : finalResponse);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Call 1: get first TARc (ToolA query1)
var response1 = await agent.RunAsync(
[new ChatMessage(ChatRole.User, "Do something")],
session);
Assert.Single(response1.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>());
// Call 2: always approve ToolA → should auto-approve ToolA query2 in queue, return ToolB
var alwaysApprove = tarc1.CreateAlwaysApproveToolResponse("Always approve ToolA");
var response2 = await agent.RunAsync(
[new ChatMessage(ChatRole.User, [alwaysApprove])],
session);
var requests2 = response2.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(requests2);
Assert.Equal("ToolB", ((FunctionCallContent)requests2[0].ToolCall).Name);
}
/// <summary>
/// Verify that once all queued items are resolved, the inner agent is called with all collected responses.
/// </summary>
[Fact]
public async Task RunAsync_AllQueuedResolved_CallsInnerAgentWithCollectedResponsesAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA"));
var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB"));
var innerResponse1 = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc1, tarc2])]);
var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "All tools executed")]);
var callCount = 0;
List<ChatMessage>? thirdCallMessages = null;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) =>
{
callCount++;
if (callCount == 2)
{
thirdCallMessages = msgs.ToList();
}
})
.ReturnsAsync(() => callCount == 1 ? innerResponse1 : finalResponse);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Call 1: get first TARc
await agent.RunAsync(
[new ChatMessage(ChatRole.User, "Do something")],
session);
// Call 2: approve first, get second from queue
var approval1 = tarc1.CreateResponse(approved: true, reason: "OK");
await agent.RunAsync(
[new ChatMessage(ChatRole.User, [approval1])],
session);
// Call 3: approve second → queue empty → inner agent called
var approval2 = tarc2.CreateResponse(approved: true, reason: "OK");
var response3 = await agent.RunAsync(
[new ChatMessage(ChatRole.User, [approval2])],
session);
// Assert — inner agent called twice (initial + after queue resolved)
Assert.Equal(2, callCount);
Assert.Equal("All tools executed", response3.Text);
// Verify collected responses were injected
Assert.NotNull(thirdCallMessages);
var injectedResponses = thirdCallMessages!.SelectMany(m => m.Contents).OfType<ToolApprovalResponseContent>().ToList();
Assert.Equal(2, injectedResponses.Count);
}
/// <summary>
/// Verify that a single TARc (no excess) is returned without queueing.
/// </summary>
[Fact]
public async Task RunAsync_SingleTARc_NoQueueingAsync()
{
// Arrange
var tarc = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool"));
var innerResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc])]);
var innerAgent = CreateMockAgent(innerResponse);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Act
var response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, "Hi")],
new ChatClientAgentSession());
// Assert — single TARc returned directly
var requests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(requests);
Assert.Equal("MyTool", ((FunctionCallContent)requests[0].ToolCall).Name);
}
/// <summary>
/// Verify that denying a queued item collects the denial and proceeds to the next.
/// </summary>
[Fact]
public async Task RunAsync_DenyFirst_ReturnsSecondFromQueueAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA"));
var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB"));
var innerResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc1, tarc2])]);
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>((_, _, _, _) => callCount++)
.ReturnsAsync(innerResponse);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Call 1: get first TARc
await agent.RunAsync(
[new ChatMessage(ChatRole.User, "Do something")],
session);
// Call 2: deny first, get second from queue
var denial = tarc1.CreateResponse(approved: false, reason: "No");
var response2 = await agent.RunAsync(
[new ChatMessage(ChatRole.User, [denial])],
session);
// Assert — second TARc returned, inner agent called only once
Assert.Equal(1, callCount);
var requests = response2.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(requests);
Assert.Equal("ToolB", ((FunctionCallContent)requests[0].ToolCall).Name);
}
#endregion
#region Queue Behavior - Streaming
/// <summary>
/// Verify that when streaming yields multiple unapproved TARc, only the first is yielded.
/// </summary>
[Fact]
public async Task RunStreamingAsync_MultipleTARc_YieldsOnlyFirstAsync()
{
// Arrange
var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA"));
var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB"));
var approvalUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List<AIContent> { tarc1, tarc2 });
var textUpdate = new AgentResponseUpdate(ChatRole.Assistant, "Searching...");
var innerAgent = CreateMockStreamingAgent([textUpdate, approvalUpdate]);
var agent = new ToolApprovalAgent(innerAgent.Object);
// Act
var results = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(
[new ChatMessage(ChatRole.User, "Search")],
new ChatClientAgentSession()))
{
results.Add(update);
}
// Assert — text update + one TARc
Assert.Equal(2, results.Count);
Assert.Equal("Searching...", results[0].Text);
var requests = results[1].Contents.OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(requests);
Assert.Equal("ToolA", ((FunctionCallContent)requests[0].ToolCall).Name);
}
/// <summary>
/// Verify that streaming returns queued items one at a time on subsequent calls.
/// </summary>
[Fact]
public async Task RunStreamingAsync_ApproveFirst_ReturnsSecondFromQueueAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA"));
var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB"));
var approvalUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List<AIContent> { tarc1, tarc2 });
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>((_, _, _, _) => callCount++)
.Returns(ToAsyncEnumerableAsync(new[] { approvalUpdate }));
var agent = new ToolApprovalAgent(innerAgent.Object);
// Call 1: get first TARc
var results1 = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(
[new ChatMessage(ChatRole.User, "Search")],
session))
{
results1.Add(update);
}
Assert.Single(results1);
Assert.Equal("ToolA", ((FunctionCallContent)results1[0].Contents.OfType<ToolApprovalRequestContent>().Single().ToolCall).Name);
// Call 2: approve first, get second from queue
var approval = tarc1.CreateResponse(approved: true, reason: "OK");
var results2 = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(
[new ChatMessage(ChatRole.User, [approval])],
session))
{
results2.Add(update);
}
// Assert — second TARc returned from queue, inner agent called only once
Assert.Equal(1, callCount);
Assert.Single(results2);
Assert.Equal("ToolB", ((FunctionCallContent)results2[0].Contents.OfType<ToolApprovalRequestContent>().Single().ToolCall).Name);
}
/// <summary>
/// Verify that "always approve" during streaming queue auto-approves matching items.
/// </summary>
[Fact]
public async Task RunStreamingAsync_AlwaysApproveFirst_AutoApprovesMatchingInQueueAsync()
{
// Arrange
var session = new ChatClientAgentSession();
// Two ToolA calls + one ToolB call
var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA"));
var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolA"));
var tarc3 = new ToolApprovalRequestContent("req3", new FunctionCallContent("call3", "ToolB"));
var approvalUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List<AIContent> { tarc1, tarc2, tarc3 });
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(ToAsyncEnumerableAsync(new[] { approvalUpdate }));
var agent = new ToolApprovalAgent(innerAgent.Object);
// Call 1: get first TARc (ToolA)
var results1 = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(
[new ChatMessage(ChatRole.User, "Search")],
session))
{
results1.Add(update);
}
// Call 2: always approve ToolA → second ToolA auto-approved, returns ToolB
var alwaysApprove = tarc1.CreateAlwaysApproveToolResponse();
var results2 = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(
[new ChatMessage(ChatRole.User, [alwaysApprove])],
session))
{
results2.Add(update);
}
// Assert — ToolB returned (ToolA[2] was auto-approved)
Assert.Single(results2);
var requests = results2[0].Contents.OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(requests);
Assert.Equal("ToolB", ((FunctionCallContent)requests[0].ToolCall).Name);
}
#endregion
#region Helpers
private static Mock<AIAgent> CreateMockAgent(AgentResponse response)
{
var mock = new Mock<AIAgent>();
mock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
return mock;
}
private static Mock<AIAgent> CreateMockStreamingAgent(AgentResponseUpdate[] updates)
{
var mock = new Mock<AIAgent>();
mock
.Protected()
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(ToAsyncEnumerableAsync(updates));
return mock;
}
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(
IEnumerable<T> items,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
yield return item;
await Task.Yield();
}
}
#endregion
#region Auto-Approval Rules (Heuristics)
/// <summary>
/// Verify that <see cref="ToolApprovalAgent.AllToolsAutoApprovalRule"/> approves any tool call regardless of name.
/// </summary>
[Theory]
[InlineData("ReadTool")]
[InlineData("DangerousTool")]
[InlineData("file_access_delete")]
public async Task AllToolsAutoApprovalRule_ApprovesAnyToolAsync(string toolName)
{
// Arrange
var functionCall = new FunctionCallContent("call1", toolName);
// Act
bool approved = await ToolApprovalAgent.AllToolsAutoApprovalRule(functionCall);
// Assert
Assert.True(approved);
}
/// <summary>
/// Verify that wiring <see cref="ToolApprovalAgent.AllToolsAutoApprovalRule"/> auto-approves an approval
/// request rather than surfacing it to the caller.
/// </summary>
[Fact]
public async Task RunAsync_AllToolsAutoApprovalRule_ApprovesAnyToolAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "DangerousTool"));
// Inner agent: first call returns approval request, second returns final response.
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
callCount++;
if (callCount == 1)
{
return new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]);
}
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
});
var options = new ToolApprovalAgentOptions
{
AutoApprovalRules = [ToolApprovalAgent.AllToolsAutoApprovalRule]
};
var agent = new ToolApprovalAgent(innerAgent.Object, options);
// Act
var response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, "Hi")],
session);
// Assert — the approval request was auto-approved, inner agent called twice, nothing surfaced to caller.
Assert.Equal(2, callCount);
Assert.Equal("Done", response.Text);
Assert.Empty(response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>());
}
/// <summary>
/// Verify that an auto-approval rule can approve a function call that would otherwise need user approval.
/// </summary>
[Fact]
public async Task RunAsync_AutoApprovalRule_ApprovesMatchingToolAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool"));
// Inner agent: first call returns approval request, second returns final response.
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
callCount++;
if (callCount == 1)
{
return new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]);
}
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
});
var options = new ToolApprovalAgentOptions
{
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "ReadTool")]
};
var agent = new ToolApprovalAgent(innerAgent.Object, options);
// Act
var response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, "Hi")],
session);
// Assert — the approval request was auto-approved, inner agent called twice
Assert.Equal(2, callCount);
Assert.Equal("Done", response.Text);
}
/// <summary>
/// Verify that when auto-approval rule does not match, request is surfaced to the caller.
/// </summary>
[Fact]
public async Task RunAsync_AutoApprovalRule_DoesNotMatchSurfacesToCallerAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "DangerousTool"));
var innerAgent = CreateMockAgent(new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]));
var options = new ToolApprovalAgentOptions
{
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "ReadTool")] // Only approves ReadTool
};
var agent = new ToolApprovalAgent(innerAgent.Object, options);
// Act
var response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, "Hi")],
session);
// Assert — request surfaced to caller since heuristic doesn't match
var requests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(requests);
Assert.Equal("DangerousTool", ((FunctionCallContent)requests[0].ToolCall).Name);
}
/// <summary>
/// Verify that multiple auto-approval rules are evaluated in order; first match wins.
/// </summary>
[Fact]
public async Task RunAsync_MultipleAutoApprovalRules_FirstMatchWinsAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "SpecialTool"));
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
callCount++;
if (callCount == 1)
{
return new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]);
}
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
});
var rule1Called = false;
var rule2Called = false;
var options = new ToolApprovalAgentOptions
{
AutoApprovalRules =
[
fcc => { rule1Called = true; return new ValueTask<bool>(fcc.Name == "SpecialTool"); },
fcc => { rule2Called = true; return new ValueTask<bool>(true); } // Should not be reached
]
};
var agent = new ToolApprovalAgent(innerAgent.Object, options);
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert — first rule matched, second was never called
Assert.True(rule1Called);
Assert.False(rule2Called);
}
/// <summary>
/// Verify that standing rules are evaluated before auto-approval rules.
/// </summary>
[Fact]
public async Task RunAsync_StandingRuleTakesPrecedenceOverAutoApprovalRuleAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool"));
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
callCount++;
if (callCount <= 2)
{
return new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]);
}
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
});
var heuristicCalled = false;
var options = new ToolApprovalAgentOptions
{
AutoApprovalRules = [fcc => { heuristicCalled = true; return new ValueTask<bool>(true); }]
};
var agent = new ToolApprovalAgent(innerAgent.Object, options);
// Call 1: heuristic should be called (no standing rule yet)
var response1 = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
Assert.True(heuristicCalled);
Assert.Equal("Done", response1.Text);
// Now establish a standing rule by sending AlwaysApprove
heuristicCalled = false;
callCount = 0;
var alwaysApprove = new AlwaysApproveToolApprovalResponseContent(
approvalRequest.CreateResponse(approved: true),
alwaysApproveTool: true,
alwaysApproveToolWithArguments: false);
// Call 2: standing rule should match first, heuristic should NOT be called
var response2 = await agent.RunAsync(
[new ChatMessage(ChatRole.User, [alwaysApprove])],
session);
Assert.False(heuristicCalled);
Assert.Equal("Done", response2.Text);
}
/// <summary>
/// Verify that when a batch contains a mix of heuristic-approved and standing-rule-approved
/// requests, the collected approval responses preserve the original request order rather than
/// being grouped by approval kind.
/// </summary>
[Fact]
public async Task RunAsync_MixedAutoApprovals_PreserveOriginalOrderAsync()
{
// Arrange
var session = new ChatClientAgentSession();
// Batch ordering: first request is approved by a heuristic, second by a standing rule.
var heuristicRequest = new ToolApprovalRequestContent("reqA", new FunctionCallContent("callA", "HeuristicTool"));
var standingRequest = new ToolApprovalRequestContent("reqB", new FunctionCallContent("callB", "StandingTool"));
var batchResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [heuristicRequest, standingRequest])]);
var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
var callCount = 0;
List<ChatMessage>? secondCallMessages = null;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) =>
{
callCount++;
if (callCount == 2)
{
secondCallMessages = msgs.ToList();
}
})
.ReturnsAsync(() => callCount == 1 ? batchResponse : finalResponse);
var options = new ToolApprovalAgentOptions
{
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "HeuristicTool")]
};
var agent = new ToolApprovalAgent(innerAgent.Object, options);
// Establish a standing rule for "StandingTool" via an AlwaysApprove response in the same call.
var alwaysApprove = standingRequest.CreateAlwaysApproveToolResponse("User said always");
// Act — both requests auto-approve (heuristic + standing rule), so the inner agent is re-invoked.
var response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, [alwaysApprove])],
session);
// Assert — inner agent re-called and final response returned.
Assert.Equal(2, callCount);
Assert.Equal("Done", response.Text);
// The injected approval responses must preserve the original request order: reqA before reqB,
// even though reqA was approved by a heuristic and reqB by a standing rule.
Assert.NotNull(secondCallMessages);
var injected = secondCallMessages!
.SelectMany(m => m.Contents)
.OfType<ToolApprovalResponseContent>()
.Where(r => r.RequestId is "reqA" or "reqB")
.ToList();
Assert.Equal(2, injected.Count);
Assert.Equal("reqA", injected[0].RequestId);
Assert.Equal("reqB", injected[1].RequestId);
Assert.All(injected, r => Assert.True(r.Approved));
}
/// <summary>
/// Verify that auto-approval rules work in the streaming path.
/// </summary>
[Fact]
public async Task RunStreamingAsync_AutoApprovalRule_ApprovesMatchingToolAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool"));
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(() =>
{
callCount++;
if (callCount == 1)
{
return ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, [approvalRequest])]);
}
return ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "Done")]);
});
var options = new ToolApprovalAgentOptions
{
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "ReadTool")]
};
var agent = new ToolApprovalAgent(innerAgent.Object, options);
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hi")], session))
{
updates.Add(update);
}
// Assert — the approval request was auto-approved, inner agent streamed twice
Assert.Equal(2, callCount);
Assert.Single(updates);
Assert.Equal("Done", updates[0].Text);
}
#endregion
}