Files
agent-framework/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs
westey 96d242fa7f .NET: Remove required token params from HarnessAgent, make compaction opt-in (#6409)
* Move token params from HarnessAgent constructor to options

Remove the required maxContextWindowTokens and maxOutputTokens
constructor parameters from HarnessAgent and AsHarnessAgent, replacing
them with optional MaxContextWindowTokens and MaxOutputTokens properties
on HarnessAgentOptions.

When both values are provided, compaction is enabled as before (in-loop
CompactionProvider and chat reducer on the default InMemoryChatHistory
Provider). When either is null, compaction is disabled entirely, making
it opt-in.

New constructor: HarnessAgent(IChatClient, HarnessAgentOptions?,
ILoggerFactory?, IServiceProvider?)

Closes #6333

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Improving comments.

* feat: Add custom CompactionStrategy and DisableCompaction to HarnessAgentOptions

Allow users to provide their own CompactionStrategy via options, with
a clear priority system:
1. DisableCompaction=true: no compaction regardless of other settings
2. Custom CompactionStrategy provided: use it (token params ignored)
3. Both MaxContextWindowTokens and MaxOutputTokens set: default strategy
4. Otherwise: no compaction

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: Address PR review comments on compaction opt-in

- Update chatClient param XML doc to reflect compaction is opt-in
- Strengthen compaction tests to assert ChatReducer is null/not-null
  rather than just asserting construction succeeds

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-09 13:06:00 +00:00

1824 lines
63 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
#if NET
using Microsoft.Agents.AI.Tools.Shell;
#endif
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
public class HarnessAgentTests
{
private const int TestMaxContextWindowTokens = 100_000;
private const int TestMaxOutputTokens = 10_000;
/// <summary>
/// Creates a HarnessAgent with all default features disabled to isolate tests for specific behaviors.
/// Compaction is enabled by default for backward compatibility with existing tests.
/// </summary>
private static HarnessAgentOptions CreateAllDisabledOptions() => new()
{
MaxContextWindowTokens = TestMaxContextWindowTokens,
MaxOutputTokens = TestMaxOutputTokens,
DisableToolApproval = true,
DisableOpenTelemetry = true,
DisableFileMemory = true,
DisableFileAccess = true,
DisableWebSearch = true,
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableAgentSkillsProvider = true,
};
#region Constructor Validation
/// <summary>
/// Verify that the constructor throws when chatClient is null.
/// </summary>
[Fact]
public void Constructor_ThrowsWhenChatClientIsNull()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new HarnessAgent(null!));
}
/// <summary>
/// Verify that the constructor throws when MaxContextWindowTokens is invalid (zero).
/// </summary>
[Fact]
public void Constructor_ThrowsWhenMaxContextWindowTokensIsZero()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = new HarnessAgentOptions { MaxContextWindowTokens = 0, MaxOutputTokens = TestMaxOutputTokens };
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() => new HarnessAgent(chatClient, options));
}
/// <summary>
/// Verify that the constructor throws when MaxOutputTokens equals MaxContextWindowTokens.
/// </summary>
[Fact]
public void Constructor_ThrowsWhenMaxOutputTokensEqualsContextWindow()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = new HarnessAgentOptions { MaxContextWindowTokens = 100_000, MaxOutputTokens = 100_000 };
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() => new HarnessAgent(chatClient, options));
}
/// <summary>
/// Verify that the constructor succeeds when options is null.
/// </summary>
[Fact]
public void Constructor_SucceedsWhenOptionsIsNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient);
// Assert
Assert.NotNull(agent);
}
#endregion
#region Agent Identity
/// <summary>
/// Verify that Name and Description are passed through to the inner agent.
/// </summary>
[Fact]
public void NameAndDescription_ArePassedThrough()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.Name = "TestAgent";
options.Description = "A test agent";
// Act
var agent = new HarnessAgent(chatClient, options);
// Assert
Assert.Equal("TestAgent", agent.Name);
Assert.Equal("A test agent", agent.Description);
}
/// <summary>
/// Verify that Id is passed through to the inner agent.
/// </summary>
[Fact]
public void Id_IsPassedThrough()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.Id = "my-agent-id";
// Act
var agent = new HarnessAgent(chatClient, options);
// Assert
Assert.Equal("my-agent-id", agent.Id);
}
#endregion
#region Instructions
/// <summary>
/// Verify that default instructions are used when none are provided.
/// </summary>
[Fact]
public void Instructions_DefaultsToBuiltInInstructions()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Equal(HarnessAgent.DefaultInstructions, innerAgent!.Instructions);
}
/// <summary>
/// Verify that default instructions are used when options is provided but neither HarnessInstructions nor ChatOptions.Instructions is set.
/// </summary>
[Fact]
public void Instructions_DefaultsWhenBothNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.ChatOptions = new ChatOptions { Temperature = 0.5f };
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Equal(HarnessAgent.DefaultInstructions, innerAgent!.Instructions);
}
/// <summary>
/// Verify that ChatOptions.Instructions is appended to the default HarnessInstructions.
/// </summary>
[Fact]
public void Instructions_CombinesDefaultHarnessWithAgentInstructions()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.ChatOptions = new ChatOptions { Instructions = "You are a custom assistant." };
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
var expected = $"{HarnessAgent.DefaultInstructions}\n\nYou are a custom assistant.";
Assert.Equal(expected, innerAgent!.Instructions);
}
/// <summary>
/// Verify that custom HarnessInstructions replaces the default.
/// </summary>
[Fact]
public void Instructions_CustomHarnessInstructionsReplacesDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.HarnessInstructions = "Custom harness rules.";
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Equal("Custom harness rules.", innerAgent!.Instructions);
}
/// <summary>
/// Verify that custom HarnessInstructions and ChatOptions.Instructions are combined.
/// </summary>
[Fact]
public void Instructions_CombinesCustomHarnessWithAgentInstructions()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.HarnessInstructions = "Custom harness rules.";
options.ChatOptions = new ChatOptions { Instructions = "You are a research agent." };
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Equal("Custom harness rules.\n\nYou are a research agent.", innerAgent!.Instructions);
}
/// <summary>
/// Verify that empty HarnessInstructions omits harness portion, using only agent instructions.
/// </summary>
[Fact]
public void Instructions_EmptyHarnessInstructionsUsesOnlyAgentInstructions()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.HarnessInstructions = string.Empty;
options.ChatOptions = new ChatOptions { Instructions = "Agent only instructions." };
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Equal("Agent only instructions.", innerAgent!.Instructions);
}
/// <summary>
/// Verify that empty HarnessInstructions with no agent instructions results in empty string.
/// </summary>
[Fact]
public void Instructions_EmptyHarnessInstructionsWithNoAgentInstructions()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.HarnessInstructions = string.Empty;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Equal(string.Empty, innerAgent!.Instructions);
}
#endregion
#region ChatHistoryProvider
/// <summary>
/// Verify that the default ChatHistoryProvider is InMemoryChatHistoryProvider when none is specified.
/// </summary>
[Fact]
public void ChatHistoryProvider_DefaultsToInMemory()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.IsType<InMemoryChatHistoryProvider>(innerAgent!.ChatHistoryProvider);
}
/// <summary>
/// Verify that a custom ChatHistoryProvider is used when provided.
/// </summary>
[Fact]
public void ChatHistoryProvider_UsesCustomProviderWhenSpecified()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var customProvider = new InMemoryChatHistoryProvider();
var options = CreateAllDisabledOptions();
options.ChatHistoryProvider = customProvider;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Same(customProvider, innerAgent!.ChatHistoryProvider);
}
#endregion
#region ChatClient Pipeline
/// <summary>
/// Verify that the inner agent's ChatClient includes FunctionInvokingChatClient in the pipeline.
/// </summary>
[Fact]
public void Pipeline_IncludesFunctionInvokingChatClient()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
var ficc = innerAgent!.ChatClient.GetService<FunctionInvokingChatClient>();
Assert.NotNull(ficc);
}
/// <summary>
/// Verify that the inner agent's ChatClient pipeline includes more than just the raw chat client,
/// confirming that per-service-call persistence and other decorators have been applied.
/// </summary>
[Fact]
public void Pipeline_HasDecoratedChatClient()
{
// Arrange
var mockClient = new Mock<IChatClient>();
var rawClient = mockClient.Object;
// Act
var agent = new HarnessAgent(rawClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — the pipeline wraps the raw client, so the outer client is not the same object.
Assert.NotNull(innerAgent);
Assert.NotSame(rawClient, innerAgent!.ChatClient);
}
#endregion
#region AIContextProviders
/// <summary>
/// Verify that additional AIContextProviders from options are passed to the inner ChatClientAgent.
/// </summary>
[Fact]
public void AIContextProviders_ArePassedToInnerAgent()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var customProvider = new TodoProvider();
var options = CreateAllDisabledOptions();
options.AIContextProviders = [customProvider];
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — the custom provider should appear in the inner agent's AIContextProviders.
Assert.NotNull(innerAgent);
Assert.NotNull(innerAgent!.AIContextProviders);
Assert.Contains(customProvider, innerAgent.AIContextProviders!);
}
/// <summary>
/// Verify that when all default providers are disabled and no user AIContextProviders are specified,
/// the inner agent has an empty providers list.
/// </summary>
[Fact]
public void AIContextProviders_IsEmptyWhenAllDisabledAndNoneSpecified()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.NotNull(innerAgent!.AIContextProviders);
Assert.Empty(innerAgent.AIContextProviders!);
}
#endregion
#region ChatOptions and Tools
/// <summary>
/// Verify that tools from ChatOptions are passed to the model during invocation.
/// </summary>
[Fact]
public async Task ChatOptions_ToolsArePreservedAsync()
{
// Arrange
var tool = AIFunctionFactory.Create(() => "test", "TestTool");
var mockClient = new Mock<IChatClient>();
ChatOptions? capturedOptions = null;
mockClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
var options = CreateAllDisabledOptions();
options.ChatOptions = new ChatOptions { Tools = [tool] };
var agent = new HarnessAgent(mockClient.Object, options);
var session = await agent.CreateSessionAsync();
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert — verify the tool was included in the ChatOptions passed to the model.
Assert.NotNull(capturedOptions);
Assert.NotNull(capturedOptions!.Tools);
Assert.Contains(capturedOptions.Tools, t => t == tool);
}
/// <summary>
/// Verify that the source ChatOptions are cloned and not modified.
/// </summary>
[Fact]
public void ChatOptions_SourceIsNotModified()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var sourceChatOptions = new ChatOptions
{
Instructions = "original instructions",
Temperature = 0.7f,
};
// Act
_ = new HarnessAgent(chatClient, new HarnessAgentOptions
{
MaxContextWindowTokens = TestMaxContextWindowTokens,
MaxOutputTokens = TestMaxOutputTokens,
ChatOptions = sourceChatOptions,
});
// Assert — source ChatOptions should not be mutated.
Assert.Equal("original instructions", sourceChatOptions.Instructions);
Assert.Equal(0.7f, sourceChatOptions.Temperature);
}
#endregion
#region GetService
/// <summary>
/// Verify that GetService returns the HarnessAgent for its own type.
/// </summary>
[Fact]
public void GetService_ReturnsSelfForHarnessAgentType()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
// Assert
Assert.Same(agent, agent.GetService<HarnessAgent>());
}
/// <summary>
/// Verify that GetService returns the inner ChatClientAgent.
/// </summary>
[Fact]
public void GetService_ReturnsInnerChatClientAgent()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
// Assert
Assert.NotNull(agent.GetService<ChatClientAgent>());
}
#endregion
#region RunAsync Delegation
/// <summary>
/// Verify that RunAsync delegates to the inner ChatClientAgent.
/// </summary>
[Fact]
public async Task RunAsync_DelegatesToInnerAgentAsync()
{
// Arrange
var mockClient = new Mock<IChatClient>();
mockClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello!")));
var agent = new HarnessAgent(mockClient.Object, CreateAllDisabledOptions());
var session = await agent.CreateSessionAsync();
// Act
var response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, "Hi")],
session);
// Assert
Assert.NotNull(response);
Assert.True(response.Messages.Any());
}
#endregion
#region DefaultInstructions
/// <summary>
/// Verify that DefaultInstructions is a non-empty public constant.
/// </summary>
[Fact]
public void DefaultInstructions_IsNonEmpty()
{
// Assert
Assert.False(string.IsNullOrWhiteSpace(HarnessAgent.DefaultInstructions));
}
#endregion
#region AsHarnessAgent Extension Method
/// <summary>
/// Verify that AsHarnessAgent creates a HarnessAgent with default options.
/// </summary>
[Fact]
public void AsHarnessAgent_CreatesAgentWithDefaults()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = chatClient.AsHarnessAgent();
// Assert
Assert.NotNull(agent);
Assert.IsType<HarnessAgent>(agent);
Assert.Equal(HarnessAgent.DefaultInstructions, agent.GetService<ChatClientAgent>()!.Instructions);
}
/// <summary>
/// Verify that AsHarnessAgent passes options through to the HarnessAgent.
/// </summary>
[Fact]
public void AsHarnessAgent_PassesOptionsThrough()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.Name = "ExtensionAgent";
options.ChatOptions = new ChatOptions { Instructions = "Custom instructions" };
// Act
var agent = chatClient.AsHarnessAgent(options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.Equal("ExtensionAgent", agent.Name);
Assert.NotNull(innerAgent);
var expected = $"{HarnessAgent.DefaultInstructions}\n\nCustom instructions";
Assert.Equal(expected, innerAgent!.Instructions);
}
/// <summary>
/// Verify that AsHarnessAgent throws when chatClient is null.
/// </summary>
[Fact]
public void AsHarnessAgent_ThrowsWhenChatClientIsNull()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => ((IChatClient)null!).AsHarnessAgent());
}
#endregion
#region Feature: ToolApproval
/// <summary>
/// Verify that ToolApprovalAgent is included in the pipeline by default.
/// </summary>
[Fact]
public void ToolApproval_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableToolApproval = false;
// Act
var agent = new HarnessAgent(chatClient, options);
// Assert
Assert.NotNull(agent.GetService<ToolApprovalAgent>());
}
/// <summary>
/// Verify that ToolApprovalAgent is excluded when disabled.
/// </summary>
[Fact]
public void ToolApproval_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
// Assert
Assert.Null(agent.GetService<ToolApprovalAgent>());
}
/// <summary>
/// Verify that ToolApprovalAgentOptions auto-approval rules are passed through and actually used.
/// </summary>
[Fact]
public async Task ToolApproval_AutoApprovalRulesAreAppliedAsync()
{
// Arrange — inner client returns an approval request on first call, then final response on second.
var callCount = 0;
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool"));
var mockClient = new Mock<IChatClient>();
mockClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
callCount++;
if (callCount == 1)
{
return new ChatResponse(new ChatMessage(ChatRole.Assistant, [approvalRequest]));
}
return new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done"));
});
var options = CreateAllDisabledOptions();
options.DisableToolApproval = false;
options.ToolApprovalAgentOptions = new ToolApprovalAgentOptions
{
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "ReadTool")]
};
var agent = new HarnessAgent(mockClient.Object, options);
var session = await agent.CreateSessionAsync();
// Act
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert — the auto-approval rule approved the request, so we get "Done" (not an approval request)
Assert.Equal(2, callCount);
Assert.Equal("Done", response.Text);
}
#endregion
#region Feature: NonApprovalRequiredFunctionBypassing
/// <summary>
/// Verify that by default, when a response contains a mix of tools that require approval and tools that do not,
/// only the approval-required tool is surfaced to the caller. The non-approval-required tool is bypassed
/// (stored as auto-approved) by the <c>NonApprovalRequiredFunctionBypassingChatClient</c> decorator.
/// </summary>
[Fact]
public async Task NonApprovalRequiredFunctionBypassing_BypassesNonApprovalToolsByDefaultAsync()
{
// Arrange — the model requests both a normal tool and an approval-required tool in the same turn.
var normalTool = AIFunctionFactory.Create(() => "result", "NormalTool");
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "ApprovalTool"));
var mockClient = new Mock<IChatClient>();
mockClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new ChatResponse(new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("call1", "NormalTool"),
new FunctionCallContent("call2", "ApprovalTool"),
])));
// Disable ToolApproval so the approval requests surface in the response instead of being handled.
var options = CreateAllDisabledOptions();
options.ChatOptions = new ChatOptions { Tools = [normalTool, approvalTool] };
var agent = new HarnessAgent(mockClient.Object, options);
var session = await agent.CreateSessionAsync();
// Act
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert — only the approval-required tool surfaces as an approval request; the normal tool is bypassed.
var approvalRequests = response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.ToList();
var approvalRequest = Assert.Single(approvalRequests);
Assert.Equal("ApprovalTool", Assert.IsType<FunctionCallContent>(approvalRequest.ToolCall).Name);
}
/// <summary>
/// Verify that when bypassing is disabled, all tools (including those that do not require approval) are surfaced
/// as approval requests, reflecting the all-or-nothing behavior of <see cref="FunctionInvokingChatClient"/>.
/// </summary>
[Fact]
public async Task NonApprovalRequiredFunctionBypassing_SurfacesAllApprovalsWhenDisabledAsync()
{
// Arrange — the model requests both a normal tool and an approval-required tool in the same turn.
var normalTool = AIFunctionFactory.Create(() => "result", "NormalTool");
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "ApprovalTool"));
var mockClient = new Mock<IChatClient>();
mockClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new ChatResponse(new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("call1", "NormalTool"),
new FunctionCallContent("call2", "ApprovalTool"),
])));
var options = CreateAllDisabledOptions();
options.DisableNonApprovalRequiredFunctionBypassing = true;
options.ChatOptions = new ChatOptions { Tools = [normalTool, approvalTool] };
var agent = new HarnessAgent(mockClient.Object, options);
var session = await agent.CreateSessionAsync();
// Act
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert — both tools surface as approval requests because bypassing is disabled.
var approvalRequests = response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.Select(r => ((FunctionCallContent)r.ToolCall).Name)
.ToList();
Assert.Equal(2, approvalRequests.Count);
Assert.Contains("NormalTool", approvalRequests);
Assert.Contains("ApprovalTool", approvalRequests);
}
#endregion
#region Feature: OpenTelemetry
/// <summary>
/// Verify that OpenTelemetryAgent is included in the pipeline by default.
/// </summary>
[Fact]
public void OpenTelemetry_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableOpenTelemetry = false;
// Act
var agent = new HarnessAgent(chatClient, options);
// Assert
Assert.NotNull(agent.GetService<OpenTelemetryAgent>());
}
/// <summary>
/// Verify that OpenTelemetryAgent is excluded when disabled.
/// </summary>
[Fact]
public void OpenTelemetry_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
// Assert
Assert.Null(agent.GetService<OpenTelemetryAgent>());
}
/// <summary>
/// Verify that a custom OpenTelemetrySourceName is accepted without error.
/// </summary>
[Fact]
public void OpenTelemetry_CustomSourceNameIsAccepted()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableOpenTelemetry = false;
options.OpenTelemetrySourceName = "MyApp.AgentTracing";
// Act
var agent = new HarnessAgent(chatClient, options);
// Assert
Assert.NotNull(agent.GetService<OpenTelemetryAgent>());
}
#endregion
#region Feature: WebSearch
/// <summary>
/// Verify that HostedWebSearchTool is added to ChatOptions.Tools by default.
/// </summary>
[Fact]
public async Task WebSearch_IncludedByDefaultAsync()
{
// Arrange
var mockClient = new Mock<IChatClient>();
ChatOptions? capturedOptions = null;
mockClient
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
var options = CreateAllDisabledOptions();
options.DisableWebSearch = false;
var agent = new HarnessAgent(mockClient.Object, options);
var session = await agent.CreateSessionAsync();
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert
Assert.NotNull(capturedOptions?.Tools);
Assert.Contains(capturedOptions!.Tools!, t => t is HostedWebSearchTool);
}
/// <summary>
/// Verify that HostedWebSearchTool is not added when disabled.
/// </summary>
[Fact]
public async Task WebSearch_ExcludedWhenDisabledAsync()
{
// Arrange
var mockClient = new Mock<IChatClient>();
ChatOptions? capturedOptions = null;
mockClient
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
var agent = new HarnessAgent(mockClient.Object, CreateAllDisabledOptions());
var session = await agent.CreateSessionAsync();
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert
Assert.NotNull(capturedOptions);
if (capturedOptions!.Tools != null)
{
Assert.DoesNotContain(capturedOptions.Tools, t => t is HostedWebSearchTool);
}
}
/// <summary>
/// Verify that user-provided tools are preserved alongside the default HostedWebSearchTool.
/// </summary>
[Fact]
public async Task WebSearch_CoexistsWithUserToolsAsync()
{
// Arrange
var mockClient = new Mock<IChatClient>();
ChatOptions? capturedOptions = null;
mockClient
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
var userTool = AIFunctionFactory.Create(() => "test", "UserTool");
var options = CreateAllDisabledOptions();
options.DisableWebSearch = false;
options.ChatOptions = new ChatOptions { Tools = [userTool] };
var agent = new HarnessAgent(mockClient.Object, options);
var session = await agent.CreateSessionAsync();
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert
Assert.NotNull(capturedOptions?.Tools);
Assert.Contains(capturedOptions!.Tools!, t => t is HostedWebSearchTool);
Assert.Contains(capturedOptions.Tools!, t => t == userTool);
}
#endregion
#region Feature: TodoProvider
/// <summary>
/// Verify that TodoProvider is included in AIContextProviders by default.
/// </summary>
[Fact]
public void TodoProvider_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableTodoProvider = false;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is TodoProvider);
}
/// <summary>
/// Verify that TodoProvider is excluded when disabled.
/// </summary>
[Fact]
public void TodoProvider_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is TodoProvider);
}
}
#endregion
#region Feature: AgentModeProvider
/// <summary>
/// Verify that AgentModeProvider is included in AIContextProviders by default.
/// </summary>
[Fact]
public void AgentModeProvider_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableAgentModeProvider = false;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is AgentModeProvider);
}
/// <summary>
/// Verify that AgentModeProvider is excluded when disabled.
/// </summary>
[Fact]
public void AgentModeProvider_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is AgentModeProvider);
}
}
/// <summary>
/// Verify that custom AgentModeProviderOptions are passed through.
/// </summary>
[Fact]
public void AgentModeProvider_UsesCustomOptions()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableAgentModeProvider = false;
options.AgentModeProviderOptions = new AgentModeProviderOptions
{
Modes =
[
new AgentModeProviderOptions.AgentMode("custom-mode", "A custom mode for testing"),
],
DefaultMode = "custom-mode",
};
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — AgentModeProvider should be present (we can't easily inspect its internal options,
// but we verify it is created and present).
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is AgentModeProvider);
}
#endregion
#region Feature: FileMemoryProvider
/// <summary>
/// Verify that FileMemoryProvider is included in AIContextProviders by default.
/// </summary>
[Fact]
public void FileMemoryProvider_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableFileMemory = false;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is FileMemoryProvider);
}
/// <summary>
/// Verify that FileMemoryProvider is excluded when disabled.
/// </summary>
[Fact]
public void FileMemoryProvider_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is FileMemoryProvider);
}
}
/// <summary>
/// Verify that a custom FileMemoryStore is used when provided.
/// </summary>
[Fact]
public void FileMemoryProvider_UsesCustomStore()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var customStore = new Mock<AgentFileStore>().Object;
var options = CreateAllDisabledOptions();
options.DisableFileMemory = false;
options.FileMemoryStore = customStore;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — FileMemoryProvider should be present with the custom store.
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is FileMemoryProvider);
}
#endregion
#region Feature: FileAccessProvider
/// <summary>
/// Verify that FileAccessProvider is included in AIContextProviders by default.
/// </summary>
[Fact]
public void FileAccessProvider_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableFileAccess = false;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is FileAccessProvider);
}
/// <summary>
/// Verify that FileAccessProvider is excluded when disabled.
/// </summary>
[Fact]
public void FileAccessProvider_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is FileAccessProvider);
}
}
/// <summary>
/// Verify that a custom FileAccessStore is used when provided.
/// </summary>
[Fact]
public void FileAccessProvider_UsesCustomStore()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var customStore = new Mock<AgentFileStore>().Object;
var options = CreateAllDisabledOptions();
options.DisableFileAccess = false;
options.FileAccessStore = customStore;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — FileAccessProvider should be present with the custom store.
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is FileAccessProvider);
}
#endregion
#region Feature: AgentSkillsProvider
/// <summary>
/// Verify that AgentSkillsProvider is included in AIContextProviders by default.
/// </summary>
[Fact]
public void AgentSkillsProvider_IncludedByDefault()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableAgentSkillsProvider = false;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is AgentSkillsProvider);
}
/// <summary>
/// Verify that AgentSkillsProvider is excluded when disabled.
/// </summary>
[Fact]
public void AgentSkillsProvider_ExcludedWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is AgentSkillsProvider);
}
}
/// <summary>
/// Verify that a custom AgentSkillsSource is used when provided.
/// </summary>
[Fact]
public void AgentSkillsProvider_UsesCustomSource()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var customSource = new Mock<AgentSkillsSource>().Object;
var options = CreateAllDisabledOptions();
options.DisableAgentSkillsProvider = false;
options.AgentSkillsSource = customSource;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — AgentSkillsProvider should be present.
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is AgentSkillsProvider);
}
#endregion
#region Feature: MaximumIterationsPerRequest
/// <summary>
/// Verify that MaximumIterationsPerRequest configures the FunctionInvokingChatClient.
/// </summary>
[Fact]
public void MaximumIterationsPerRequest_ConfiguresFunctionInvokingChatClient()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.MaximumIterationsPerRequest = 42;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
var ficc = innerAgent!.ChatClient.GetService<FunctionInvokingChatClient>();
// Assert
Assert.NotNull(ficc);
Assert.Equal(42, ficc!.MaximumIterationsPerRequest);
}
/// <summary>
/// Verify that the default MaximumIterationsPerRequest is used when not set.
/// </summary>
[Fact]
public void MaximumIterationsPerRequest_UsesDefaultWhenNotSet()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
var ficc = innerAgent!.ChatClient.GetService<FunctionInvokingChatClient>();
// Assert — default is not 0 and not our custom value.
Assert.NotNull(ficc);
Assert.NotEqual(0, ficc!.MaximumIterationsPerRequest);
}
#endregion
#region Feature: All Defaults Enabled
/// <summary>
/// Verify that when no options are provided, all default features are enabled.
/// </summary>
[Fact]
public async Task AllDefaults_AllFeaturesEnabledAsync()
{
// Arrange
var mockClient = new Mock<IChatClient>();
ChatOptions? capturedOptions = null;
mockClient
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
// Act
var agent = new HarnessAgent(mockClient.Object);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — agent wrappers
Assert.NotNull(agent.GetService<ToolApprovalAgent>());
Assert.NotNull(agent.GetService<OpenTelemetryAgent>());
// Assert — default context providers
Assert.NotNull(innerAgent);
Assert.NotNull(innerAgent!.AIContextProviders);
var providers = innerAgent.AIContextProviders!.ToList();
Assert.Contains(providers, p => p is TodoProvider);
Assert.Contains(providers, p => p is AgentModeProvider);
Assert.Contains(providers, p => p is FileMemoryProvider);
Assert.Contains(providers, p => p is FileAccessProvider);
Assert.Contains(providers, p => p is AgentSkillsProvider);
// Assert — HostedWebSearchTool is present in the tools sent to the model
var session = await agent.CreateSessionAsync();
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
Assert.NotNull(capturedOptions?.Tools);
Assert.Contains(capturedOptions!.Tools!, t => t is HostedWebSearchTool);
}
#endregion
#region Feature: BackgroundAgentsProvider
/// <summary>
/// Verify that BackgroundAgentsProvider is included when BackgroundAgents are specified.
/// </summary>
[Fact]
public void BackgroundAgentsProvider_IncludedWhenAgentsSpecified()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var bgAgentMock = new Mock<AIAgent>();
bgAgentMock.Setup(a => a.Name).Returns("TestBackgroundAgent");
var options = CreateAllDisabledOptions();
options.BackgroundAgents = [bgAgentMock.Object];
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is BackgroundAgentsProvider);
}
/// <summary>
/// Verify that BackgroundAgentsProvider is not included when BackgroundAgents is null.
/// </summary>
[Fact]
public void BackgroundAgentsProvider_ExcludedWhenAgentsNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.BackgroundAgents = null;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is BackgroundAgentsProvider);
}
}
/// <summary>
/// Verify that BackgroundAgentsProvider is not included when BackgroundAgents is an empty collection.
/// </summary>
[Fact]
public void BackgroundAgentsProvider_ExcludedWhenAgentsEmpty()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.BackgroundAgents = Array.Empty<AIAgent>();
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
if (innerAgent!.AIContextProviders != null)
{
Assert.DoesNotContain(innerAgent.AIContextProviders, p => p is BackgroundAgentsProvider);
}
}
/// <summary>
/// Verify that BackgroundAgentsProviderOptions is passed through when specified.
/// </summary>
[Fact]
public async Task BackgroundAgentsProvider_UsesProvidedOptionsAsync()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var bgAgentMock = new Mock<AIAgent>();
bgAgentMock.Setup(a => a.Name).Returns("TestBackgroundAgent");
bgAgentMock.Setup(a => a.Description).Returns("A test background agent");
var providerOptions = new BackgroundAgentsProviderOptions
{
Instructions = "Custom instructions with {background_agents} list.",
};
var options = CreateAllDisabledOptions();
options.BackgroundAgents = [bgAgentMock.Object];
options.BackgroundAgentsProviderOptions = providerOptions;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
var bgProvider = innerAgent!.AIContextProviders!.OfType<BackgroundAgentsProvider>().Single();
#pragma warning disable MAAI001
var invokingContext = new AIContextProvider.InvokingContext(
new Mock<AIAgent>().Object,
new Mock<AgentSession>().Object,
new AIContext());
#pragma warning restore MAAI001
AIContext result = await bgProvider.InvokingAsync(invokingContext);
// Assert — custom instructions template is used and agent info is included
Assert.NotNull(result.Instructions);
Assert.Contains("Custom instructions with", result.Instructions);
Assert.Contains("TestBackgroundAgent", result.Instructions);
}
/// <summary>
/// Verify that multiple background agents are all passed to the provider.
/// </summary>
[Fact]
public async Task BackgroundAgentsProvider_IncludesMultipleAgentsAsync()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var agent1Mock = new Mock<AIAgent>();
agent1Mock.Setup(a => a.Name).Returns("Agent1");
agent1Mock.Setup(a => a.Description).Returns("First agent");
var agent2Mock = new Mock<AIAgent>();
agent2Mock.Setup(a => a.Name).Returns("Agent2");
agent2Mock.Setup(a => a.Description).Returns("Second agent");
var options = CreateAllDisabledOptions();
options.BackgroundAgents = [agent1Mock.Object, agent2Mock.Object];
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
var bgProvider = innerAgent!.AIContextProviders!.OfType<BackgroundAgentsProvider>().Single();
#pragma warning disable MAAI001
var invokingContext = new AIContextProvider.InvokingContext(
new Mock<AIAgent>().Object,
new Mock<AgentSession>().Object,
new AIContext());
#pragma warning restore MAAI001
AIContext result = await bgProvider.InvokingAsync(invokingContext);
// Assert — both agents appear in the provider's instructions
Assert.NotNull(result.Instructions);
Assert.Contains("Agent1", result.Instructions);
Assert.Contains("First agent", result.Instructions);
Assert.Contains("Agent2", result.Instructions);
Assert.Contains("Second agent", result.Instructions);
}
#endregion
#if NET
#region Feature: ShellEnvironmentProvider
/// <summary>
/// Verify that ShellEnvironmentProvider is included when ShellExecutor is provided.
/// </summary>
[Fact]
public void ShellEnvironmentProvider_IncludedWhenExecutorProvided()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var executorMock = new Mock<ShellExecutor>();
executorMock.Setup(e => e.AsAIFunction(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<bool>()))
.Returns(AIFunctionFactory.Create(() => "test", "run_shell"));
var options = CreateAllDisabledOptions();
options.ShellExecutor = executorMock.Object;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is ShellEnvironmentProvider);
}
/// <summary>
/// Verify that ShellEnvironmentProvider is not included when ShellExecutor is null.
/// </summary>
[Fact]
public void ShellEnvironmentProvider_ExcludedWhenExecutorNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.ShellExecutor = null;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.NotNull(innerAgent!.AIContextProviders);
Assert.DoesNotContain(innerAgent.AIContextProviders!, p => p is ShellEnvironmentProvider);
}
/// <summary>
/// Verify that the shell tool AIFunction is added to ChatOptions.Tools when ShellExecutor is provided.
/// </summary>
[Fact]
public async Task ShellExecutor_ToolAddedToChatOptionsAsync()
{
// Arrange
ChatOptions? capturedOptions = null;
var chatClientMock = new Mock<IChatClient>();
chatClientMock
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")));
var executorMock = new Mock<ShellExecutor>();
executorMock.Setup(e => e.AsAIFunction(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<bool>()))
.Returns(AIFunctionFactory.Create(() => "shell output", "run_shell"));
var options = CreateAllDisabledOptions();
options.DisableWebSearch = true;
options.ShellExecutor = executorMock.Object;
// Act
var agent = new HarnessAgent(chatClientMock.Object, options);
var session = await agent.CreateSessionAsync();
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert — the shell tool should be present
Assert.NotNull(capturedOptions?.Tools);
Assert.Contains(capturedOptions!.Tools!, t => t is AIFunction f && f.Name == "run_shell");
}
/// <summary>
/// Verify that ShellEnvironmentProvider is present when ShellEnvironmentProviderOptions is also specified.
/// </summary>
[Fact]
public void ShellEnvironmentProvider_PresentWhenOptionsProvided()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var executorMock = new Mock<ShellExecutor>();
executorMock.Setup(e => e.AsAIFunction(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<bool>()))
.Returns(AIFunctionFactory.Create(() => "test", "run_shell"));
var envOptions = new ShellEnvironmentProviderOptions
{
ProbeTools = ["git", "python"],
};
var options = CreateAllDisabledOptions();
options.ShellExecutor = executorMock.Object;
options.ShellEnvironmentProviderOptions = envOptions;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — provider should exist (options wiring is validated by the provider's behavior)
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is ShellEnvironmentProvider);
}
#endregion
#endif
#region LoggerFactory and ServiceProvider
/// <summary>
/// Verify that the constructor succeeds when loggerFactory is provided.
/// </summary>
[Fact]
public void Constructor_SucceedsWithLoggerFactory()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var loggerFactory = new Mock<ILoggerFactory>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions(), loggerFactory);
// Assert
Assert.NotNull(agent);
}
/// <summary>
/// Verify that the constructor succeeds when serviceProvider is provided.
/// </summary>
[Fact]
public void Constructor_SucceedsWithServiceProvider()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var services = new Mock<IServiceProvider>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions(), services: services);
// Assert
Assert.NotNull(agent);
}
/// <summary>
/// Verify that the constructor succeeds when both loggerFactory and serviceProvider are provided.
/// </summary>
[Fact]
public void Constructor_SucceedsWithLoggerFactoryAndServiceProvider()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var loggerFactory = new Mock<ILoggerFactory>().Object;
var services = new Mock<IServiceProvider>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions(), loggerFactory, services);
// Assert
Assert.NotNull(agent);
}
/// <summary>
/// Verify that AsHarnessAgent extension method accepts loggerFactory and serviceProvider.
/// </summary>
[Fact]
public void AsHarnessAgent_SucceedsWithLoggerFactoryAndServiceProvider()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var loggerFactory = new Mock<ILoggerFactory>().Object;
var services = new Mock<IServiceProvider>().Object;
// Act
var agent = chatClient.AsHarnessAgent(CreateAllDisabledOptions(), loggerFactory, services);
// Assert
Assert.NotNull(agent);
}
/// <summary>
/// Verify that ILoggerFactory is threaded to downstream components by confirming CreateLogger is called.
/// </summary>
[Fact]
public void Constructor_LoggerFactoryIsUsedByDownstreamComponents()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var mockLoggerFactory = new Mock<ILoggerFactory>();
mockLoggerFactory
.Setup(lf => lf.CreateLogger(It.IsAny<string>()))
.Returns(new Mock<ILogger>().Object);
// Act — use options that leave CompactionProvider and AgentSkillsProvider enabled
var options = new HarnessAgentOptions
{
MaxContextWindowTokens = TestMaxContextWindowTokens,
MaxOutputTokens = TestMaxOutputTokens,
DisableToolApproval = true,
DisableOpenTelemetry = true,
DisableFileMemory = true,
DisableFileAccess = true,
DisableWebSearch = true,
DisableTodoProvider = true,
DisableAgentModeProvider = true,
};
var agent = new HarnessAgent(chatClient, options, mockLoggerFactory.Object);
// Assert — CreateLogger should have been called by one or more downstream components
Assert.NotNull(agent);
mockLoggerFactory.Verify(lf => lf.CreateLogger(It.IsAny<string>()), Times.AtLeastOnce());
}
/// <summary>
/// Verify that IServiceProvider is propagated through the agent pipeline by confirming
/// it is queried during agent construction.
/// </summary>
[Fact]
public void Constructor_ServiceProviderIsQueriedDuringBuild()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var mockServices = new Mock<IServiceProvider>();
mockServices
.Setup(sp => sp.GetService(It.IsAny<Type>()))
.Returns(null!);
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions(), services: mockServices.Object);
// Assert — the service provider should have been queried during pipeline construction
Assert.NotNull(agent);
mockServices.Verify(sp => sp.GetService(It.IsAny<Type>()), Times.AtLeastOnce());
}
#endregion
#region Compaction Opt-in
/// <summary>
/// Verify that constructing without token values succeeds (compaction disabled).
/// </summary>
[Fact]
public void Constructor_SucceedsWithoutTokenValues()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = new HarnessAgentOptions
{
DisableToolApproval = true,
DisableOpenTelemetry = true,
DisableFileMemory = true,
DisableFileAccess = true,
DisableWebSearch = true,
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableAgentSkillsProvider = true,
};
// Act
var agent = new HarnessAgent(chatClient, options);
// Assert — compaction should be disabled (no chat reducer)
var innerAgent = agent.GetService<ChatClientAgent>();
Assert.NotNull(innerAgent);
var historyProvider = innerAgent!.ChatHistoryProvider as InMemoryChatHistoryProvider;
Assert.NotNull(historyProvider);
Assert.Null(historyProvider!.ChatReducer);
}
/// <summary>
/// Verify that when only MaxContextWindowTokens is provided (no MaxOutputTokens), compaction is disabled.
/// </summary>
[Fact]
public void Constructor_SucceedsWithOnlyMaxContextWindowTokens()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = new HarnessAgentOptions
{
MaxContextWindowTokens = TestMaxContextWindowTokens,
DisableToolApproval = true,
DisableOpenTelemetry = true,
DisableFileMemory = true,
DisableFileAccess = true,
DisableWebSearch = true,
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableAgentSkillsProvider = true,
};
// Act
var agent = new HarnessAgent(chatClient, options);
// Assert — compaction should be disabled (only one token value provided)
var innerAgent = agent.GetService<ChatClientAgent>();
Assert.NotNull(innerAgent);
var historyProvider = innerAgent!.ChatHistoryProvider as InMemoryChatHistoryProvider;
Assert.NotNull(historyProvider);
Assert.Null(historyProvider!.ChatReducer);
}
/// <summary>
/// Verify that when both token values are provided, the agent is constructed successfully with compaction.
/// </summary>
[Fact]
public void Constructor_SucceedsWithBothTokenValues()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
// Assert — compaction should be enabled (chat reducer configured)
var innerAgent = agent.GetService<ChatClientAgent>();
Assert.NotNull(innerAgent);
var historyProvider = innerAgent!.ChatHistoryProvider as InMemoryChatHistoryProvider;
Assert.NotNull(historyProvider);
Assert.NotNull(historyProvider!.ChatReducer);
}
#endregion
}