chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,481 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AIAgentBuilder"/> class.
/// </summary>
public class AIAgentBuilderTests
{
/// <summary>
/// Verify that constructor throws ArgumentNullException when innerAgent is null.
/// </summary>
[Fact]
public void Constructor_WithNullInnerAgent_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("innerAgent", () => new AIAgentBuilder((AIAgent)null!));
}
/// <summary>
/// Verify that constructor throws ArgumentNullException when innerAgentFactory is null.
/// </summary>
[Fact]
public void Constructor_WithNullInnerAgentFactory_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("innerAgentFactory", () => new AIAgentBuilder((Func<IServiceProvider, AIAgent>)null!));
}
/// <summary>
/// Verify that Build returns the inner agent when no middleware is added.
/// </summary>
[Fact]
public void Build_WithNoMiddleware_ReturnsInnerAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.Build();
// Assert
Assert.Same(mockAgent.Object, result);
}
/// <summary>
/// Verify that Build works with factory function.
/// </summary>
[Fact]
public void Build_WithFactory_ReturnsAgentFromFactory()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(_ => mockAgent.Object);
// Act
var result = builder.Build();
// Assert
Assert.Same(mockAgent.Object, result);
}
/// <summary>
/// Verify that Use with simple factory works correctly.
/// </summary>
[Fact]
public void Use_WithSimpleFactory_AppliesMiddleware()
{
// Arrange
var mockInnerAgent = new Mock<AIAgent>();
var mockOuterAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockInnerAgent.Object);
// Act
var result = builder.Use(innerAgent =>
{
Assert.Same(mockInnerAgent.Object, innerAgent);
return mockOuterAgent.Object;
}).Build();
// Assert
Assert.Same(mockOuterAgent.Object, result);
}
/// <summary>
/// Verify that Use with service provider factory works correctly.
/// </summary>
[Fact]
public void Use_WithServiceProviderFactory_AppliesMiddleware()
{
// Arrange
var mockInnerAgent = new Mock<AIAgent>();
var mockOuterAgent = new Mock<AIAgent>();
var mockServiceProvider = new Mock<IServiceProvider>();
var builder = new AIAgentBuilder(mockInnerAgent.Object);
// Act
var result = builder.Use((innerAgent, services) =>
{
Assert.Same(mockInnerAgent.Object, innerAgent);
Assert.NotNull(services);
return mockOuterAgent.Object;
}).Build(mockServiceProvider.Object);
// Assert
Assert.Same(mockOuterAgent.Object, result);
}
/// <summary>
/// Verify that multiple middleware are applied in correct order (first added is outermost).
/// </summary>
[Fact]
public void Use_WithMultipleMiddleware_AppliesInCorrectOrder()
{
// Arrange
var mockInnerAgent = new Mock<AIAgent>();
var mockMiddleAgent = new Mock<AIAgent>();
var mockOuterAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockInnerAgent.Object);
// Act
var result = builder
.Use(innerAgent =>
{
// First middleware added (will be outermost) - should receive result of second middleware
Assert.Same(mockMiddleAgent.Object, innerAgent);
return mockOuterAgent.Object;
})
.Use(innerAgent =>
{
// Second middleware added (will be applied first) - should receive the original inner agent
Assert.Same(mockInnerAgent.Object, innerAgent);
return mockMiddleAgent.Object;
})
.Build();
// Assert
// The result should be from the first middleware since it's the outermost
Assert.Same(mockOuterAgent.Object, result);
}
/// <summary>
/// Verify that Use throws ArgumentNullException when agentFactory is null.
/// </summary>
[Fact]
public void Use_WithNullSimpleFactory_ThrowsArgumentNullException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>("agentFactory", () => builder.Use((Func<AIAgent, AIAgent>)null!));
}
/// <summary>
/// Verify that Use throws ArgumentNullException when agentFactory with service provider is null.
/// </summary>
[Fact]
public void Use_WithNullServiceProviderFactory_ThrowsArgumentNullException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>("agentFactory", () => builder.Use((Func<AIAgent, IServiceProvider, AIAgent>)null!));
}
/// <summary>
/// Verify that Build throws InvalidOperationException when middleware returns null.
/// </summary>
[Fact]
public void Build_WithMiddlewareReturningNull_ThrowsInvalidOperationException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() =>
builder.Use(_ => null!).Build());
Assert.Contains("returned null", exception.Message);
Assert.Contains("AIAgentBuilder", exception.Message);
}
/// <summary>
/// Verify that Build uses EmptyServiceProvider when services is null.
/// </summary>
[Fact]
public void Build_WithNullServices_UsesEmptyServiceProvider()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
IServiceProvider? capturedServices = null;
// Act
builder.Use((agent, services) =>
{
capturedServices = services;
return agent;
}).Build(null);
// Assert
Assert.NotNull(capturedServices);
Assert.Null(capturedServices.GetService(typeof(string))); // EmptyServiceProvider returns null for everything
}
/// <summary>
/// Verify that service provider is passed correctly to factories.
/// </summary>
[Fact]
public void PassesServiceProviderToFactories()
{
// Arrange
var expectedServiceProvider = new ServiceCollection().BuildServiceProvider();
var mockInnerAgent = new Mock<AIAgent>();
var mockOuterAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(services =>
{
Assert.Same(expectedServiceProvider, services);
return mockInnerAgent.Object;
});
builder.Use((innerAgent, serviceProvider) =>
{
Assert.Same(expectedServiceProvider, serviceProvider);
Assert.Same(mockInnerAgent.Object, innerAgent);
return mockOuterAgent.Object;
});
// Act
var result = builder.Build(expectedServiceProvider);
// Assert
Assert.Same(mockOuterAgent.Object, result);
}
/// <summary>
/// Verify that pipeline is built in the order added (first added is outermost).
/// </summary>
[Fact]
public void BuildsPipelineInOrderAdded()
{
// Arrange
var mockInnerAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockInnerAgent.Object)
.Use(next => new InnerAgentCapturingAgent("First", next))
.Use(next => new InnerAgentCapturingAgent("Second", next))
.Use(next => new InnerAgentCapturingAgent("Third", next));
// Act
var first = (InnerAgentCapturingAgent)builder.Build();
// Assert
Assert.Equal("First", first.TestName);
var second = (InnerAgentCapturingAgent)first.InnerAgent;
Assert.Equal("Second", second.TestName);
var third = (InnerAgentCapturingAgent)second.InnerAgent;
Assert.Equal("Third", third.TestName);
Assert.Same(mockInnerAgent.Object, third.InnerAgent);
}
/// <summary>
/// Verify that factories cannot return null.
/// </summary>
[Fact]
public void DoesNotAllowFactoriesToReturnNull()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
builder.Use(_ => null!);
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => builder.Build());
Assert.Contains("entry at index 0", ex.Message);
}
/// <summary>
/// Verify that EmptyServiceProvider is used when no services are provided and supports keyed services.
/// </summary>
[Fact]
public void UsesEmptyServiceProviderWhenNoServicesProvided()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
builder.Use((innerAgent, serviceProvider) =>
{
Assert.Null(serviceProvider.GetService(typeof(object)));
var keyedServiceProvider = Assert.IsType<IKeyedServiceProvider>(serviceProvider, exactMatch: false);
Assert.Null(keyedServiceProvider.GetKeyedService(typeof(object), "key"));
Assert.Throws<InvalidOperationException>(() => keyedServiceProvider.GetRequiredKeyedService(typeof(object), "key"));
return innerAgent;
});
builder.Build();
}
#region Delegate Overload Tests
/// <summary>
/// Verify that Use with shared delegate throws ArgumentNullException when sharedFunc is null.
/// </summary>
[Fact]
public void Use_WithNullSharedFunc_ThrowsArgumentNullException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>("sharedFunc", () =>
builder.Use((Func<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, Func<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken, Task>, CancellationToken, Task>)null!));
}
/// <summary>
/// Verify that Use with both delegates null throws ArgumentNullException.
/// </summary>
[Fact]
public void Use_WithBothDelegatesNull_ThrowsArgumentNullException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
builder.Use(null, null));
Assert.Contains("runFunc", exception.Message);
}
/// <summary>
/// Verify that Use with shared delegate creates AnonymousDelegatingAIAgent.
/// </summary>
[Fact]
public void Use_WithSharedDelegate_CreatesAnonymousDelegatingAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.Use((_, _, _, _, _) => Task.CompletedTask).Build();
// Assert
Assert.IsType<AnonymousDelegatingAIAgent>(result);
}
/// <summary>
/// Verify that Use with runFunc only creates AnonymousDelegatingAIAgent.
/// </summary>
[Fact]
public void Use_WithRunFuncOnly_CreatesAnonymousDelegatingAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.Use((_, _, _, _, _) => Task.FromResult(new AgentResponse()), null).Build();
// Assert
Assert.IsType<AnonymousDelegatingAIAgent>(result);
}
/// <summary>
/// Verify that Use with runStreamingFunc only creates AnonymousDelegatingAIAgent.
/// </summary>
[Fact]
public void Use_WithStreamingFuncOnly_CreatesAnonymousDelegatingAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.Use(null, (_, _, _, _, _) => AsyncEnumerable.Empty<AgentResponseUpdate>()).Build();
// Assert
Assert.IsType<AnonymousDelegatingAIAgent>(result);
}
/// <summary>
/// Verify that Use with both delegates creates AnonymousDelegatingAIAgent.
/// </summary>
[Fact]
public void Use_WithBothDelegates_CreatesAnonymousDelegatingAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.Use(
(_, _, _, _, _) => Task.FromResult(new AgentResponse()),
(_, _, _, _, _) => AsyncEnumerable.Empty<AgentResponseUpdate>()).Build();
// Assert
Assert.IsType<AnonymousDelegatingAIAgent>(result);
}
/// <summary>
/// Verify that Use with both delegates allows both to access AgentRunContext.
/// </summary>
[Fact]
public async Task Use_WithBothDelegates_AllowsDelegateToAccessAgentRunContextAsync()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var mockSession = new Mock<AgentSession>();
var builder = new AIAgentBuilder(mockAgent.Object);
AIAgent? builtAgent = null;
bool nonStreamingMiddlewareExecuted = false;
bool streamingMiddlwareExecuted = true;
builtAgent = builder.Use(
(_, _, _, _, _) =>
{
Assert.NotNull(AIAgent.CurrentRunContext);
Assert.Same(builtAgent, AIAgent.CurrentRunContext.Agent);
Assert.Same(mockSession.Object, AIAgent.CurrentRunContext.Session);
nonStreamingMiddlewareExecuted = true;
return Task.FromResult(new AgentResponse());
},
(_, _, _, _, _) =>
{
Assert.NotNull(AIAgent.CurrentRunContext);
Assert.Same(builtAgent, AIAgent.CurrentRunContext.Agent);
Assert.Same(mockSession.Object, AIAgent.CurrentRunContext.Session);
streamingMiddlwareExecuted = true;
return AsyncEnumerable.Empty<AgentResponseUpdate>();
}).Build();
// Act
await builtAgent.RunAsync("Input message", mockSession.Object);
await foreach (var update in builtAgent.RunStreamingAsync("Input message", mockSession.Object))
{
}
// Assert
Assert.True(nonStreamingMiddlewareExecuted);
Assert.True(streamingMiddlwareExecuted);
}
#endregion
/// <summary>
/// Helper class for testing pipeline order.
/// </summary>
private sealed class InnerAgentCapturingAgent : DelegatingAIAgent
{
public string TestName { get; }
public new AIAgent InnerAgent => base.InnerAgent;
public InnerAgentCapturingAgent(string name, AIAgent innerAgent) : base(innerAgent)
{
this.TestName = name;
}
}
}
@@ -0,0 +1,591 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AIContextProviderChatClient"/> class and
/// the <see cref="AIContextProviderChatClientBuilderExtensions.UseAIContextProviders(ChatClientBuilder, AIContextProvider[])"/> builder extension.
/// </summary>
public class AIContextProviderChatClientTests
{
private static readonly AgentSession s_mockSession = new Mock<AgentSession>().Object;
#region Constructor Tests
[Fact]
public void Constructor_NullInnerClient_ThrowsArgumentNullException()
{
// Arrange
var provider = new TestAIContextProvider("key1");
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new AIContextProviderChatClient(null!, [provider]));
}
[Fact]
public void Constructor_NullProviders_ThrowsArgumentNullException()
{
// Arrange
var innerClient = new Mock<IChatClient>().Object;
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new AIContextProviderChatClient(innerClient, null!));
}
[Fact]
public void Constructor_EmptyProviders_ThrowsArgumentException()
{
// Arrange
var innerClient = new Mock<IChatClient>().Object;
// Act & Assert
Assert.Throws<ArgumentException>(() => new AIContextProviderChatClient(innerClient, []));
}
#endregion
#region GetResponseAsync Tests
[Fact]
public async Task GetResponseAsync_NoRunContext_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
var innerClient = new Mock<IChatClient>();
var provider = new TestAIContextProvider("key1");
var chatClient = new AIContextProviderChatClient(innerClient.Object, [provider]);
// Act & Assert — no AIAgent.CurrentRunContext is set
await Assert.ThrowsAsync<InvalidOperationException>(
() => chatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "Hello")]));
}
[Fact]
public async Task GetResponseAsync_SingleProvider_EnrichesMessagesAsync()
{
// Arrange
IEnumerable<ChatMessage>? capturedMessages = null;
var innerClient = CreateMockChatClient(
onGetResponse: (messages, _, _) =>
{
capturedMessages = messages;
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
});
var provider = new TestAIContextProvider("key1", provideMessages: [new ChatMessage(ChatRole.System, "Extra context")]);
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
// Act — run through an agent so CurrentRunContext is set
await RunWithAgentContextAsync(chatClient);
// Assert
Assert.NotNull(capturedMessages);
var messageList = capturedMessages!.ToList();
Assert.Equal(2, messageList.Count);
Assert.Equal("Hello", messageList[0].Text);
Assert.Contains("Extra context", messageList[1].Text);
}
[Fact]
public async Task GetResponseAsync_MultipleProviders_CalledInSequenceAsync()
{
// Arrange
IEnumerable<ChatMessage>? capturedMessages = null;
var innerClient = CreateMockChatClient(
onGetResponse: (messages, _, _) =>
{
capturedMessages = messages;
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
});
var provider1 = new TestAIContextProvider("key1", provideMessages: [new ChatMessage(ChatRole.System, "From P1")]);
var provider2 = new TestAIContextProvider("key2", provideMessages: [new ChatMessage(ChatRole.System, "From P2")]);
var chatClient = new AIContextProviderChatClient(innerClient, [provider1, provider2]);
// Act
await RunWithAgentContextAsync(chatClient);
// Assert
Assert.NotNull(capturedMessages);
var messageList = capturedMessages!.ToList();
Assert.Equal(3, messageList.Count);
}
[Fact]
public async Task GetResponseAsync_Provider_EnrichesToolsAndInstructionsAsync()
{
// Arrange
ChatOptions? capturedOptions = null;
var innerClient = CreateMockChatClient(
onGetResponse: (_, options, _) =>
{
capturedOptions = options;
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
});
var provider = new TestAIContextProvider("key1", provideInstructions: "Extra instructions", provideTools: [new TestAITool()]);
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
// Act
await RunWithAgentContextAsync(chatClient);
// Assert
Assert.NotNull(capturedOptions);
Assert.Equal("Extra instructions", capturedOptions!.Instructions);
Assert.Single(capturedOptions.Tools!);
}
[Fact]
public async Task GetResponseAsync_OnSuccess_InvokedAsyncCalledAsync()
{
// Arrange
var innerClient = CreateMockChatClient(
onGetResponse: (_, _, _) => Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")])));
var provider = new TestAIContextProvider("key1");
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
// Act
await RunWithAgentContextAsync(chatClient);
// Assert
Assert.True(provider.InvokedAsyncCalled);
Assert.Null(provider.LastInvokedContext!.InvokeException);
Assert.NotNull(provider.LastInvokedContext.ResponseMessages);
}
[Fact]
public async Task GetResponseAsync_OnFailure_InvokedAsyncCalledWithExceptionAsync()
{
// Arrange
var expectedException = new InvalidOperationException("Chat failed");
var innerClient = CreateMockChatClient(
onGetResponse: (_, _, _) => throw expectedException);
var provider = new TestAIContextProvider("key1");
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => RunWithAgentContextAsync(chatClient));
Assert.True(provider.InvokedAsyncCalled);
Assert.Same(expectedException, provider.LastInvokedContext!.InvokeException);
}
#endregion
#region GetStreamingResponseAsync Tests
[Fact]
public async Task GetStreamingResponseAsync_SingleProvider_EnrichesAndStreamsAsync()
{
// Arrange
IEnumerable<ChatMessage>? capturedMessages = null;
var innerClient = CreateMockStreamingChatClient(
onGetStreamingResponse: (messages, _, _) =>
{
capturedMessages = messages;
return ToAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "Part1"),
new ChatResponseUpdate(ChatRole.Assistant, "Part2"));
});
var provider = new TestAIContextProvider("key1", provideMessages: [new ChatMessage(ChatRole.System, "Extra context")]);
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
// Act
var updates = new List<ChatResponseUpdate>();
await RunStreamingWithAgentContextAsync(chatClient, updates);
// Assert
Assert.Equal(2, updates.Count);
Assert.NotNull(capturedMessages);
Assert.Equal(2, capturedMessages!.ToList().Count);
}
[Fact]
public async Task GetStreamingResponseAsync_OnSuccess_InvokedAsyncCalledAsync()
{
// Arrange
var innerClient = CreateMockStreamingChatClient(
onGetStreamingResponse: (_, _, _) => ToAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "Response")));
var provider = new TestAIContextProvider("key1");
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
// Act
await RunStreamingWithAgentContextAsync(chatClient, []);
// Assert
Assert.True(provider.InvokedAsyncCalled);
Assert.Null(provider.LastInvokedContext!.InvokeException);
}
[Fact]
public async Task GetStreamingResponseAsync_OnFailure_InvokedAsyncCalledWithExceptionAsync()
{
// Arrange
var expectedException = new InvalidOperationException("Stream failed");
var innerClient = CreateMockStreamingChatClient(
onGetStreamingResponse: (_, _, _) => throw expectedException);
var provider = new TestAIContextProvider("key1");
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => RunStreamingWithAgentContextAsync(chatClient, []));
Assert.True(provider.InvokedAsyncCalled);
Assert.Same(expectedException, provider.LastInvokedContext!.InvokeException);
}
#endregion
#region Shared Options Tests
[Fact]
public async Task GetResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync()
{
// Arrange: track tool count seen by the inner client on each call
var toolCountsSeenByInner = new List<int>();
var innerClient = CreateMockChatClient(
onGetResponse: (_, options, _) =>
{
toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0);
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
});
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
var sharedOptions = new ChatOptions
{
Tools = new List<AITool> { new TestAITool() }
};
// Act: make 3 calls reusing the same ChatOptions
for (int i = 0; i < 3; i++)
{
await RunWithAgentContextAsync(chatClient, sharedOptions);
}
// Assert: each call should see exactly 2 tools (1 baseline + 1 injected)
Assert.Equal(3, toolCountsSeenByInner.Count);
Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count));
}
[Fact]
public async Task GetResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync()
{
// Arrange
var innerClient = CreateMockChatClient(
onGetResponse: (_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")])));
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
var baselineTool = new TestAITool();
var originalTools = new List<AITool> { baselineTool };
var sharedOptions = new ChatOptions
{
Tools = originalTools
};
// Act
await RunWithAgentContextAsync(chatClient, sharedOptions);
// Assert: the original list should still contain only the baseline tool
Assert.Single(originalTools);
Assert.Same(baselineTool, originalTools[0]);
Assert.Same(originalTools, sharedOptions.Tools);
Assert.Same(baselineTool, originalTools[0]);
}
[Fact]
public async Task GetStreamingResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync()
{
// Arrange
var toolCountsSeenByInner = new List<int>();
var innerClient = CreateMockStreamingChatClient(
onGetStreamingResponse: (_, options, _) =>
{
toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0);
return ToAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "Response"));
});
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
var sharedOptions = new ChatOptions
{
Tools = new List<AITool> { new TestAITool() }
};
// Act: make 3 streaming calls reusing the same ChatOptions
for (int i = 0; i < 3; i++)
{
await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions);
}
// Assert: each call should see exactly 2 tools (1 baseline + 1 injected)
Assert.Equal(3, toolCountsSeenByInner.Count);
Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count));
}
[Fact]
public async Task GetStreamingResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync()
{
// Arrange
var innerClient = CreateMockStreamingChatClient(
onGetStreamingResponse: (_, _, _) => ToAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "Response")));
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
var baselineTool = new TestAITool();
var originalTools = new List<AITool> { baselineTool };
var sharedOptions = new ChatOptions
{
Tools = originalTools
};
// Act
await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions);
// Assert: the original list should still contain only the baseline tool
Assert.Single(originalTools);
Assert.Same(baselineTool, originalTools[0]);
}
#endregion
#region Builder Extension Tests
[Fact]
public void UseExtension_NullBuilder_ThrowsArgumentNullException()
{
// Arrange
var provider = new TestAIContextProvider("key1");
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
AIContextProviderChatClientBuilderExtensions.UseAIContextProviders(null!, provider));
}
[Fact]
public async Task UseExtension_CreatesWorkingPipelineAsync()
{
// Arrange
IEnumerable<ChatMessage>? capturedMessages = null;
var innerClient = CreateMockChatClient(
onGetResponse: (messages, _, _) =>
{
capturedMessages = messages;
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
});
var provider = new TestAIContextProvider("key1", provideMessages: [new ChatMessage(ChatRole.System, "Pipeline context")]);
var pipeline = new ChatClientBuilder(innerClient)
.UseAIContextProviders(provider)
.Build();
// Act — wrap in an agent to set CurrentRunContext
var agent = new TestAIAgent
{
RunAsyncFunc = async (messages, session, options, ct) =>
{
var response = await pipeline.GetResponseAsync(messages, cancellationToken: ct);
return new AgentResponse(response);
}
};
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
// Assert
Assert.NotNull(capturedMessages);
var messageList = capturedMessages!.ToList();
Assert.Equal(2, messageList.Count);
}
#endregion
#region Helpers
/// <summary>
/// Runs a chat client within an agent context so that AIAgent.CurrentRunContext is set.
/// </summary>
private static async Task RunWithAgentContextAsync(AIContextProviderChatClient chatClient)
{
var agent = new TestAIAgent
{
RunAsyncFunc = async (messages, session, options, ct) =>
{
var response = await chatClient.GetResponseAsync(messages, cancellationToken: ct);
return new AgentResponse(response);
}
};
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
}
/// <summary>
/// Runs a streaming chat client within an agent context so that AIAgent.CurrentRunContext is set.
/// </summary>
private static async Task RunStreamingWithAgentContextAsync(AIContextProviderChatClient chatClient, List<ChatResponseUpdate> updates)
{
var agent = new TestAIAgent
{
RunAsyncFunc = async (messages, session, options, ct) =>
{
await foreach (var update in chatClient.GetStreamingResponseAsync(messages, cancellationToken: ct))
{
updates.Add(update);
}
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]);
}
};
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
}
/// <summary>
/// Runs a chat client within an agent context with the specified options.
/// </summary>
private static async Task RunWithAgentContextAsync(AIContextProviderChatClient chatClient, ChatOptions options)
{
var agent = new TestAIAgent
{
RunAsyncFunc = async (messages, session, agentOptions, ct) =>
{
var response = await chatClient.GetResponseAsync(messages, options, ct);
return new AgentResponse(response);
}
};
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
}
/// <summary>
/// Runs a streaming chat client within an agent context with the specified options.
/// </summary>
private static async Task RunStreamingWithAgentContextAsync(AIContextProviderChatClient chatClient, List<ChatResponseUpdate> updates, ChatOptions options)
{
var agent = new TestAIAgent
{
RunAsyncFunc = async (messages, session, agentOptions, ct) =>
{
await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options, ct))
{
updates.Add(update);
}
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]);
}
};
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
}
private static IChatClient CreateMockChatClient(
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, Task<ChatResponse>> onGetResponse)
{
var mock = new Mock<IChatClient>();
mock.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions?>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> m, ChatOptions? o, CancellationToken ct) => onGetResponse(m, o, ct));
return mock.Object;
}
private static IChatClient CreateMockStreamingChatClient(
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, IAsyncEnumerable<ChatResponseUpdate>> onGetStreamingResponse)
{
var mock = new Mock<IChatClient>();
mock.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions?>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> m, ChatOptions? o, CancellationToken ct) => onGetStreamingResponse(m, o, ct));
return mock.Object;
}
private static async IAsyncEnumerable<ChatResponseUpdate> ToAsyncEnumerableAsync(params ChatResponseUpdate[] updates)
{
foreach (var update in updates)
{
yield return update;
}
await Task.CompletedTask;
}
/// <summary>
/// A test AIContextProvider that provides configurable messages, tools, and instructions.
/// </summary>
private sealed class TestAIContextProvider : AIContextProvider
{
private readonly IReadOnlyList<string> _stateKeys;
private readonly IEnumerable<ChatMessage> _provideMessages;
private readonly string? _provideInstructions;
private readonly IEnumerable<AITool>? _provideTools;
public bool InvokedAsyncCalled { get; private set; }
public InvokedContext? LastInvokedContext { get; private set; }
public override IReadOnlyList<string> StateKeys => this._stateKeys;
public TestAIContextProvider(
string stateKey,
IEnumerable<ChatMessage>? provideMessages = null,
string? provideInstructions = null,
IEnumerable<AITool>? provideTools = null)
{
this._stateKeys = [stateKey];
this._provideMessages = provideMessages ?? [];
this._provideInstructions = provideInstructions;
this._provideTools = provideTools;
}
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
return new ValueTask<AIContext>(new AIContext
{
Messages = this._provideMessages,
Instructions = this._provideInstructions,
Tools = this._provideTools,
});
}
protected override ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
this.InvokedAsyncCalled = true;
this.LastInvokedContext = context;
return default;
}
}
/// <summary>
/// A minimal AITool for testing.
/// </summary>
private sealed class TestAITool : AITool;
#endregion
}
@@ -0,0 +1,469 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="MessageAIContextProviderAgent"/> class and
/// the <see cref="AIAgentBuilder.UseAIContextProviders(MessageAIContextProvider[])"/> builder extension.
/// </summary>
public class MessageAIContextProviderAgentTests
{
private static readonly AgentSession s_mockSession = new Mock<AgentSession>().Object;
#region Constructor Tests
[Fact]
public void Constructor_NullInnerAgent_ThrowsArgumentNullException()
{
// Arrange
var provider = new TestProvider();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new MessageAIContextProviderAgent(null!, [provider]));
}
[Fact]
public void Constructor_NullProviders_ThrowsArgumentNullException()
{
// Arrange
var agent = CreateTestAgent();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new MessageAIContextProviderAgent(agent, null!));
}
[Fact]
public void Constructor_EmptyProviders_ThrowsArgumentOutOfRangeException()
{
// Arrange
var agent = CreateTestAgent();
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() => new MessageAIContextProviderAgent(agent, []));
}
#endregion
#region RunAsync Tests
[Fact]
public async Task RunAsync_SingleProvider_EnrichesMessagesAndDelegatesToInnerAgentAsync()
{
// Arrange
var contextMessage = new ChatMessage(ChatRole.System, "Extra context");
var provider = new TestProvider(provideMessages: [contextMessage]);
IEnumerable<ChatMessage>? capturedMessages = null;
var innerAgent = CreateTestAgent(
runFunc: (messages, _, _, _) =>
{
capturedMessages = messages;
return Task.FromResult(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
});
var agent = new MessageAIContextProviderAgent(innerAgent, [provider]);
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
// Assert - inner agent received enriched messages (input + provider's message)
Assert.NotNull(capturedMessages);
var messageList = capturedMessages!.ToList();
Assert.Equal(2, messageList.Count);
Assert.Equal("Hello", messageList[0].Text);
Assert.Contains("Extra context", messageList[1].Text);
}
[Fact]
public async Task RunAsync_MultipleProviders_CalledInSequenceAsync()
{
// Arrange
var provider1 = new TestProvider(provideMessages: [new ChatMessage(ChatRole.System, "From provider 1")]);
var provider2 = new TestProvider(provideMessages: [new ChatMessage(ChatRole.System, "From provider 2")]);
IEnumerable<ChatMessage>? capturedMessages = null;
var innerAgent = CreateTestAgent(
runFunc: (messages, _, _, _) =>
{
capturedMessages = messages;
return Task.FromResult(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
});
var agent = new MessageAIContextProviderAgent(innerAgent, [provider1, provider2]);
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
// Assert - inner agent received messages from both providers in sequence
Assert.NotNull(capturedMessages);
var messageList = capturedMessages!.ToList();
Assert.Equal(3, messageList.Count);
Assert.Equal("Hello", messageList[0].Text);
Assert.Contains("From provider 1", messageList[1].Text);
Assert.Contains("From provider 2", messageList[2].Text);
}
[Fact]
public async Task RunAsync_SequentialProviders_EachReceivesPreviousOutputAsync()
{
// Arrange - provider 2 captures the filtered messages it receives in ProvideMessagesAsync.
// The default filter only includes External messages, so provider 1's stamped messages
// (marked as AIContextProvider) are filtered out before reaching provider 2's ProvideMessagesAsync.
// However, the full unfiltered output from provider 1 is passed to provider 2's InvokingAsync,
// and the inner agent receives the full merged output from both providers.
IEnumerable<ChatMessage>? provider2ReceivedMessages = null;
var provider1 = new TestProvider(provideMessages: [new ChatMessage(ChatRole.System, "From provider 1")]);
var provider2 = new TestProvider(
provideMessages: [new ChatMessage(ChatRole.System, "From provider 2")],
onInvoking: messages => provider2ReceivedMessages = messages.ToList());
var innerAgent = CreateTestAgent(
runFunc: (_, _, _, _) => Task.FromResult(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Response")])));
var agent = new MessageAIContextProviderAgent(innerAgent, [provider1, provider2]);
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
// Assert - provider 2's ProvideMessagesAsync received only External messages (filtered)
Assert.NotNull(provider2ReceivedMessages);
var received = provider2ReceivedMessages!.ToList();
Assert.Single(received);
Assert.Equal("Hello", received[0].Text);
}
[Fact]
public async Task RunAsync_OnSuccess_InvokedAsyncCalledOnAllProvidersAsync()
{
// Arrange
var provider1 = new TestProvider();
var provider2 = new TestProvider();
var innerAgent = CreateTestAgent(
runFunc: (_, _, _, _) => Task.FromResult(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Response")])));
var agent = new MessageAIContextProviderAgent(innerAgent, [provider1, provider2]);
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
// Assert
Assert.True(provider1.InvokedAsyncCalled);
Assert.True(provider2.InvokedAsyncCalled);
Assert.Null(provider1.LastInvokedContext!.InvokeException);
Assert.Null(provider2.LastInvokedContext!.InvokeException);
}
[Fact]
public async Task RunAsync_OnFailure_InvokedAsyncCalledWithExceptionAsync()
{
// Arrange
var provider = new TestProvider();
var expectedException = new InvalidOperationException("Agent failed");
var innerAgent = CreateTestAgent(
runFunc: (_, _, _, _) => throw expectedException);
var agent = new MessageAIContextProviderAgent(innerAgent, [provider]);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() =>
agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession));
Assert.True(provider.InvokedAsyncCalled);
Assert.Same(expectedException, provider.LastInvokedContext!.InvokeException);
}
[Fact]
public async Task RunAsync_OnSuccess_InvokedContextContainsResponseMessagesAsync()
{
// Arrange
var provider = new TestProvider();
var responseMessage = new ChatMessage(ChatRole.Assistant, "Response text");
var innerAgent = CreateTestAgent(
runFunc: (_, _, _, _) => Task.FromResult(new AgentResponse([responseMessage])));
var agent = new MessageAIContextProviderAgent(innerAgent, [provider]);
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
// Assert
Assert.NotNull(provider.LastInvokedContext?.ResponseMessages);
Assert.Contains(provider.LastInvokedContext!.ResponseMessages!, m => m.Text == "Response text");
}
#endregion
#region RunStreamingAsync Tests
[Fact]
public async Task RunStreamingAsync_SingleProvider_EnrichesMessagesAndStreamsAsync()
{
// Arrange
var contextMessage = new ChatMessage(ChatRole.System, "Extra context");
var provider = new TestProvider(provideMessages: [contextMessage]);
IEnumerable<ChatMessage>? capturedMessages = null;
var innerAgent = CreateTestAgent(
runStreamingFunc: (messages, _, _, _) =>
{
capturedMessages = messages;
return ToAsyncEnumerableAsync(
new AgentResponseUpdate(ChatRole.Assistant, "Part1"),
new AgentResponseUpdate(ChatRole.Assistant, "Part2"));
});
var agent = new MessageAIContextProviderAgent(innerAgent, [provider]);
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession))
{
updates.Add(update);
}
// Assert - streaming updates received
Assert.Equal(2, updates.Count);
// Assert - inner agent received enriched messages
Assert.NotNull(capturedMessages);
var messageList = capturedMessages!.ToList();
Assert.Equal(2, messageList.Count);
}
[Fact]
public async Task RunStreamingAsync_OnSuccess_InvokedAsyncCalledAfterAllUpdatesAsync()
{
// Arrange
var provider = new TestProvider();
var innerAgent = CreateTestAgent(
runStreamingFunc: (_, _, _, _) => ToAsyncEnumerableAsync(
new AgentResponseUpdate(ChatRole.Assistant, "Response")));
var agent = new MessageAIContextProviderAgent(innerAgent, [provider]);
// Act - consume all updates
await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession))
{
}
// Assert
Assert.True(provider.InvokedAsyncCalled);
Assert.Null(provider.LastInvokedContext!.InvokeException);
}
[Fact]
public async Task RunStreamingAsync_OnSuccess_InvokedContextContainsAccumulatedResponseAsync()
{
// Arrange
var provider = new TestProvider();
var innerAgent = CreateTestAgent(
runStreamingFunc: (_, _, _, _) => ToAsyncEnumerableAsync(
new AgentResponseUpdate(ChatRole.Assistant, "Hello "),
new AgentResponseUpdate(ChatRole.Assistant, "World")));
var agent = new MessageAIContextProviderAgent(innerAgent, [provider]);
// Act - consume all updates
await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession))
{
}
// Assert - InvokedAsync received the accumulated response messages
Assert.NotNull(provider.LastInvokedContext?.ResponseMessages);
var responseMessages = provider.LastInvokedContext!.ResponseMessages!.ToList();
Assert.True(responseMessages.Count > 0);
}
[Fact]
public async Task RunStreamingAsync_OnFailure_InvokedAsyncCalledWithExceptionAsync()
{
// Arrange
var provider = new TestProvider();
var expectedException = new InvalidOperationException("Stream failed");
var innerAgent = CreateTestAgent(
runStreamingFunc: (_, _, _, _) => throw expectedException);
var agent = new MessageAIContextProviderAgent(innerAgent, [provider]);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession))
{
}
});
Assert.True(provider.InvokedAsyncCalled);
Assert.Same(expectedException, provider.LastInvokedContext!.InvokeException);
}
[Fact]
public async Task RunStreamingAsync_MultipleProviders_CalledInSequenceAsync()
{
// Arrange
var provider1 = new TestProvider(provideMessages: [new ChatMessage(ChatRole.System, "From provider 1")]);
var provider2 = new TestProvider(provideMessages: [new ChatMessage(ChatRole.System, "From provider 2")]);
IEnumerable<ChatMessage>? capturedMessages = null;
var innerAgent = CreateTestAgent(
runStreamingFunc: (messages, _, _, _) =>
{
capturedMessages = messages;
return ToAsyncEnumerableAsync(new AgentResponseUpdate(ChatRole.Assistant, "Response"));
});
var agent = new MessageAIContextProviderAgent(innerAgent, [provider1, provider2]);
// Act
await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession))
{
}
// Assert
Assert.NotNull(capturedMessages);
var messageList = capturedMessages!.ToList();
Assert.Equal(3, messageList.Count);
Assert.Equal("Hello", messageList[0].Text);
Assert.Contains("From provider 1", messageList[1].Text);
Assert.Contains("From provider 2", messageList[2].Text);
}
#endregion
#region Builder Extension Tests
[Fact]
public async Task UseExtension_CreatesWorkingPipelineAsync()
{
// Arrange
var contextMessage = new ChatMessage(ChatRole.System, "Pipeline context");
var provider = new TestProvider(provideMessages: [contextMessage]);
IEnumerable<ChatMessage>? capturedMessages = null;
var innerAgent = CreateTestAgent(
runFunc: (messages, _, _, _) =>
{
capturedMessages = messages;
return Task.FromResult(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
});
var pipeline = new AIAgentBuilder(innerAgent)
.UseAIContextProviders([provider])
.Build();
// Act
await pipeline.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
// Assert
Assert.NotNull(capturedMessages);
var messageList = capturedMessages!.ToList();
Assert.Equal(2, messageList.Count);
Assert.Equal("Hello", messageList[0].Text);
Assert.Contains("Pipeline context", messageList[1].Text);
}
[Fact]
public async Task UseExtension_MultipleProviders_AllAppliedAsync()
{
// Arrange
var provider1 = new TestProvider(provideMessages: [new ChatMessage(ChatRole.System, "P1")]);
var provider2 = new TestProvider(provideMessages: [new ChatMessage(ChatRole.System, "P2")]);
IEnumerable<ChatMessage>? capturedMessages = null;
var innerAgent = CreateTestAgent(
runFunc: (messages, _, _, _) =>
{
capturedMessages = messages;
return Task.FromResult(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
});
var pipeline = new AIAgentBuilder(innerAgent)
.UseAIContextProviders([provider1, provider2])
.Build();
// Act
await pipeline.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
// Assert
Assert.NotNull(capturedMessages);
var messageList = capturedMessages!.ToList();
Assert.Equal(3, messageList.Count);
}
#endregion
#region Helpers
private static TestAIAgent CreateTestAgent(
Func<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken, Task<AgentResponse>>? runFunc = null,
Func<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken, IAsyncEnumerable<AgentResponseUpdate>>? runStreamingFunc = null)
{
var agent = new TestAIAgent();
if (runFunc is not null)
{
agent.RunAsyncFunc = runFunc;
}
if (runStreamingFunc is not null)
{
agent.RunStreamingAsyncFunc = runStreamingFunc;
}
return agent;
}
private static async IAsyncEnumerable<AgentResponseUpdate> ToAsyncEnumerableAsync(params AgentResponseUpdate[] updates)
{
foreach (var update in updates)
{
yield return update;
}
await Task.CompletedTask;
}
/// <summary>
/// A test implementation of <see cref="MessageAIContextProvider"/> that records invocation calls.
/// </summary>
private sealed class TestProvider : MessageAIContextProvider
{
private readonly IEnumerable<ChatMessage> _provideMessages;
private readonly Action<IEnumerable<ChatMessage>>? _onInvoking;
public bool InvokedAsyncCalled { get; private set; }
public InvokedContext? LastInvokedContext { get; private set; }
public TestProvider(
IEnumerable<ChatMessage>? provideMessages = null,
Action<IEnumerable<ChatMessage>>? onInvoking = null)
{
this._provideMessages = provideMessages ?? [];
this._onInvoking = onInvoking;
}
protected override ValueTask<IEnumerable<ChatMessage>> ProvideMessagesAsync(
InvokingContext context,
CancellationToken cancellationToken = default)
{
this._onInvoking?.Invoke(context.RequestMessages);
return new ValueTask<IEnumerable<ChatMessage>>(this._provideMessages);
}
protected override ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
this.InvokedAsyncCalled = true;
this.LastInvokedContext = context;
return default;
}
}
#endregion
}
@@ -0,0 +1,434 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AIAgentExtensions.AsAIFunction"/> method.
/// </summary>
public class AgentExtensionsTests
{
[Fact]
public void CreateFromAgent_WithNullAgent_ThrowsArgumentNullException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
AIAgentExtensions.AsAIFunction(null!));
Assert.Equal("agent", exception.ParamName);
}
[Fact]
public void CreateFromAgent_WithValidAgent_ReturnsAIFunction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns("Test agent description");
// Act
var result = mockAgent.Object.AsAIFunction();
// Assert
Assert.NotNull(result);
Assert.Equal("TestAgent", result.Name);
Assert.Equal("Test agent description", result.Description);
}
[Fact]
public void CreateFromAgent_WithAgentHavingNullName_UsesDefaultName()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns((string?)null);
mockAgent.Setup(a => a.Description).Returns("Test description");
// Act
var result = mockAgent.Object.AsAIFunction();
// Assert
Assert.NotNull(result);
Assert.NotNull(result.Name);
Assert.Equal("Test description", result.Description);
}
[Fact]
public void CreateFromAgent_WithAgentHavingNullDescription_UsesDefaultDescription()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns((string?)null);
// Act
var result = mockAgent.Object.AsAIFunction();
// Assert
Assert.NotNull(result);
Assert.Equal("TestAgent", result.Name);
Assert.Equal("Invoke an agent to retrieve some information.", result.Description);
}
[Fact]
public void CreateFromAgent_WithCustomOptions_UsesCustomOptions()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns("Test agent description");
var customOptions = new AIFunctionFactoryOptions
{
Name = "CustomName",
Description = "Custom description"
};
// Act
var result = mockAgent.Object.AsAIFunction(customOptions);
// Assert
Assert.NotNull(result);
Assert.Equal("CustomName", result.Name);
Assert.Equal("Custom description", result.Description);
}
[Fact]
public void CreateFromAgent_WithNullOptions_UsesAgentProperties()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns("Test agent description");
// Act
var result = mockAgent.Object.AsAIFunction(null);
// Assert
Assert.NotNull(result);
Assert.Equal("TestAgent", result.Name);
Assert.Equal("Test agent description", result.Description);
}
[Fact]
public async Task CreateFromAgent_WhenFunctionInvokedAsync_CallsAgentRunAsync()
{
// Arrange
var expectedResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
var testAgent = new TestAgent("TestAgent", "Test description", expectedResponse);
var aiFunction = testAgent.AsAIFunction();
// Act
var arguments = new AIFunctionArguments() { ["query"] = "Test query" };
var result = await aiFunction.InvokeAsync(arguments);
// Assert
Assert.NotNull(result);
Assert.Equal("Test response", result.ToString());
}
[Fact]
public async Task CreateFromAgent_WhenFunctionInvokedWithCancellationTokenAsync_PassesCancellationTokenAsync()
{
// Arrange
var expectedResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
var testAgent = new TestAgent("TestAgent", "Test description", expectedResponse);
using var cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = cancellationTokenSource.Token;
var aiFunction = testAgent.AsAIFunction();
// Act
var arguments = new AIFunctionArguments() { ["query"] = "Test query" };
var result = await aiFunction.InvokeAsync(arguments, cancellationToken);
// Assert
Assert.NotNull(result);
Assert.Equal("Test response", result.ToString());
}
[Fact]
public async Task CreateFromAgent_WhenAgentThrowsExceptionAsync_PropagatesExceptionAsync()
{
// Arrange
var expectedException = new InvalidOperationException("Test exception");
var testAgent = new TestAgent("TestAgent", "Test description", expectedException);
var aiFunction = testAgent.AsAIFunction();
// Act & Assert
var arguments = new AIFunctionArguments() { ["query"] = "Test query" };
var actualException = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await aiFunction.InvokeAsync(arguments));
Assert.Same(expectedException, actualException);
}
[Fact]
public void CreateFromAgent_ReturnsInvokableFunction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns("Test description");
// Act
var result = mockAgent.Object.AsAIFunction();
// Assert
Assert.NotNull(result);
// Verify the function has the expected parameter schema
var parameters = result.JsonSchema;
// Verify it has a query parameter
Assert.True(parameters.TryGetProperty("properties", out var properties));
Assert.True(properties.TryGetProperty("query", out var queryProperty));
Assert.True(queryProperty.TryGetProperty("type", out var typeProperty));
Assert.Equal("string", typeProperty.GetString());
}
[Fact]
public void CreateFromAgent_WithEmptyAgentName_CreatesValidFunction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns(string.Empty);
mockAgent.Setup(a => a.Description).Returns("Test description");
// Act
var result = mockAgent.Object.AsAIFunction();
// Assert
Assert.NotNull(result);
Assert.Equal(string.Empty, result.Name);
Assert.Equal("Test description", result.Description);
}
[Fact]
public void CreateFromAgent_WithEmptyAgentDescription_CreatesValidFunction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns(string.Empty);
// Act
var result = mockAgent.Object.AsAIFunction();
// Assert
Assert.NotNull(result);
Assert.Equal("TestAgent", result.Name);
Assert.Equal(string.Empty, result.Description);
}
[Fact]
public void CreateFromAgent_WithCustomOptionsOverridingNullAgentProperties_UsesCustomOptions()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns((string?)null);
mockAgent.Setup(a => a.Description).Returns((string?)null);
var customOptions = new AIFunctionFactoryOptions
{
Name = "OverrideName",
Description = "Override description"
};
// Act
var result = mockAgent.Object.AsAIFunction(customOptions);
// Assert
Assert.NotNull(result);
Assert.Equal("OverrideName", result.Name);
Assert.Equal("Override description", result.Description);
}
[Fact]
public async Task CreateFromAgent_InvokeWithComplexResponseFromAgentAsync_ReturnsCorrectResponseAsync()
{
// Arrange
var expectedResponse = new AgentResponse
{
AgentId = "agent-123",
ResponseId = "response-456",
CreatedAt = DateTimeOffset.UtcNow,
Messages = { new ChatMessage(ChatRole.Assistant, "Complex response") }
};
var testAgent = new TestAgent("TestAgent", "Test description", expectedResponse);
var aiFunction = testAgent.AsAIFunction();
// Act
var arguments = new AIFunctionArguments() { ["query"] = "Test query" };
var result = await aiFunction.InvokeAsync(arguments);
// Assert
Assert.NotNull(result);
Assert.Equal("Complex response", result.ToString());
}
[Fact]
public async Task CreateFromAgent_InvokeWithAdditionalProperties_PropagatesAdditionalPropertiesToChildAgentAsync()
{
// Arrange
var expectedResponse = new AgentResponse
{
AgentId = "agent-123",
ResponseId = "response-456",
CreatedAt = DateTimeOffset.UtcNow,
Messages = { new ChatMessage(ChatRole.Assistant, "Complex response") }
};
var testAgent = new TestAgent("TestAgent", "Test description", expectedResponse);
var aiFunction = testAgent.AsAIFunction();
// Use reflection to set the protected CurrentContext property
var context = new FunctionInvocationContext()
{
Options = new()
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
{ "customProperty1", "value1" },
{ "customProperty2", 42 }
}
}
};
SetFunctionInvokingChatClientCurrentContext(context);
// Act
var arguments = new AIFunctionArguments() { ["query"] = "Test query" };
var result = await aiFunction.InvokeAsync(arguments);
// Assert
Assert.NotNull(result);
Assert.Equal("Complex response", result.ToString());
Assert.NotNull(testAgent.ReceivedAgentRunOptions);
Assert.NotNull(testAgent.ReceivedAgentRunOptions!.AdditionalProperties);
Assert.Equal("value1", testAgent.ReceivedAgentRunOptions!.AdditionalProperties["customProperty1"]);
Assert.Equal(42, testAgent.ReceivedAgentRunOptions!.AdditionalProperties["customProperty2"]);
}
[Theory]
[InlineData("MyAgent", "MyAgent")]
[InlineData("Agent123", "Agent123")]
[InlineData("Agent_With_Underscores", "Agent_With_Underscores")]
[InlineData("Agent_With_________@@@@_Underscores", "Agent_With_Underscores")]
[InlineData("123Agent", "123Agent")]
[InlineData("My-Agent", "My_Agent")]
[InlineData("My Agent", "My_Agent")]
[InlineData("Agent@123", "Agent_123")]
[InlineData("Agent/With\\Slashes", "Agent_With_Slashes")]
[InlineData("Agent.With.Dots", "Agent_With_Dots")]
public void CreateFromAgent_SanitizesAgentName(string agentName, string expectedFunctionName)
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns(agentName);
// Act
var result = mockAgent.Object.AsAIFunction();
// Assert
Assert.NotNull(result);
Assert.Equal(expectedFunctionName, result.Name);
}
/// <summary>
/// Uses reflection to set the protected static CurrentContext property on FunctionInvokingChatClient.
/// </summary>
private static void SetFunctionInvokingChatClientCurrentContext(FunctionInvocationContext? context)
{
// Access the private static field _currentContext which is an AsyncLocal<FunctionInvocationContext?>
var currentContextField = typeof(FunctionInvokingChatClient).GetField(
"_currentContext",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
if (currentContextField?.GetValue(null) is AsyncLocal<FunctionInvocationContext?> asyncLocal)
{
asyncLocal.Value = context;
}
}
/// <summary>
/// Test implementation of AIAgent for testing purposes.
/// </summary>
private sealed class TestAgent : AIAgent
{
private readonly AgentResponse? _responseToReturn;
private readonly Exception? _exceptionToThrow;
public TestAgent(string? name, string? description, AgentResponse responseToReturn)
{
this.Name = name;
this.Description = description;
this._responseToReturn = responseToReturn;
}
public TestAgent(string? name, string? description, Exception exceptionToThrow)
{
this.Name = name;
this.Description = description;
this._exceptionToThrow = exceptionToThrow;
}
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public override string? Name { get; }
public override string? Description { get; }
public List<ChatMessage> ReceivedMessages { get; } = [];
public AgentRunOptions? ReceivedAgentRunOptions { get; private set; }
public CancellationToken LastCancellationToken { get; private set; }
public int RunAsyncCallCount { get; private set; }
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
this.RunAsyncCallCount++;
this.LastCancellationToken = cancellationToken;
this.ReceivedMessages.AddRange(messages);
this.ReceivedAgentRunOptions = options;
if (this._exceptionToThrow is not null)
{
throw this._exceptionToThrow;
}
return Task.FromResult(this._responseToReturn!);
}
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var response = await this.RunAsync(messages, session, options, cancellationToken);
foreach (var update in response.ToAgentResponseUpdates())
{
yield return update;
}
}
}
}
@@ -0,0 +1,95 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
#pragma warning disable CA1812 // Avoid uninstantiated internal classes
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Tests for <see cref="AgentJsonUtilities"/>
/// </summary>
public class AgentJsonUtilitiesTests
{
[Fact]
public void DefaultOptions_HasExpectedConfiguration()
{
var options = AgentJsonUtilities.DefaultOptions;
// Must be read-only singleton.
Assert.NotNull(options);
Assert.Same(options, AgentJsonUtilities.DefaultOptions);
Assert.True(options.IsReadOnly);
// Must conform to JsonSerializerDefaults.Web
Assert.Equal(JsonNamingPolicy.CamelCase, options.PropertyNamingPolicy);
Assert.True(options.PropertyNameCaseInsensitive);
Assert.Equal(JsonNumberHandling.AllowReadingFromString, options.NumberHandling);
// Additional settings
Assert.Equal(JsonIgnoreCondition.WhenWritingNull, options.DefaultIgnoreCondition);
Assert.Same(JavaScriptEncoder.UnsafeRelaxedJsonEscaping, options.Encoder);
}
[Theory]
[InlineData("<script>alert('XSS')</script>", "<script>alert('XSS')</script>")]
[InlineData("""{"forecast":"sunny", "temperature":"75"}""", """{\"forecast\":\"sunny\", \"temperature\":\"75\"}""")]
[InlineData("""{"message":"Πάντα ῥεῖ."}""", """{\"message\":\"Πάντα ῥεῖ.\"}""")]
[InlineData("""{"message":"七転び八起き"}""", """{\"message\":\"七転び八起き\"}""")]
[InlineData("""☺️🤖🌍𝄞""", """☺️\uD83E\uDD16\uD83C\uDF0D\uD834\uDD1E""")]
public void DefaultOptions_UsesExpectedEscaping(string input, string expectedJsonString)
{
var options = AgentJsonUtilities.DefaultOptions;
string json = JsonSerializer.Serialize(input, options);
Assert.Equal($@"""{expectedJsonString}""", json);
}
[Fact]
public void DefaultOptions_UsesReflectionWhenDefault()
{
Type anonType = new { Name = 42 }.GetType();
Assert.Equal(JsonSerializer.IsReflectionEnabledByDefault, AgentJsonUtilities.DefaultOptions.TryGetTypeInfo(anonType, out _));
}
// The following two tests validate behaviors of reflection-based serialization
// which is only available in .NET Framework builds.
#if NETFRAMEWORK
[Fact]
public void DefaultOptions_AllowsReadingNumbersFromStrings_AndOmitsNulls()
{
var obj = JsonSerializer.Deserialize<NumberContainer>(
"{\"value\":\"42\",\"optional\":null}", // value as string, optional null
AgentJsonUtilities.DefaultOptions);
Assert.NotNull(obj);
Assert.Equal(42, obj!.Value);
Assert.Null(obj.Optional);
Assert.Equal("{\"value\":42}",
JsonSerializer.Serialize(obj, AgentJsonUtilities.DefaultOptions)); // null omitted
}
[Fact]
public void DefaultOptions_SerializesEnumsAsStrings()
{
Assert.Equal("\"Monday\"", JsonSerializer.Serialize(DayOfWeek.Monday, AgentJsonUtilities.DefaultOptions));
}
#endif
[Fact]
public void DefaultOptions_UsesCamelCasePropertyNames_ForAgentResponse()
{
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Hello"));
string json = JsonSerializer.Serialize(response, AgentJsonUtilities.DefaultOptions);
Assert.Contains("\"messages\"", json);
Assert.DoesNotContain("\"Messages\"", json);
}
private sealed class NumberContainer
{
public int Value { get; set; }
public string? Optional { get; set; }
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,324 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentFileSkillScript"/>.
/// </summary>
public sealed class AgentFileSkillScriptTests
{
[Fact]
public async Task RunAsync_SkillIsNotAgentFileSkill_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>("result");
var script = CreateScript("test-script", "/path/to/script.py", RunnerAsync);
var nonFileSkill = new TestAgentSkill("my-skill", "A skill", "Instructions.");
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => script.RunAsync(nonFileSkill, null, null, CancellationToken.None));
}
[Fact]
public async Task RunAsync_WithAgentFileSkill_DelegatesToRunnerAsync()
{
// Arrange
var runnerCalled = false;
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct)
{
runnerCalled = true;
return Task.FromResult<object?>("executed");
}
var script = CreateScript("run-me", "/scripts/run-me.sh", runnerAsync);
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A file skill"),
"---\nname: my-skill\n---\nContent",
"/skills/my-skill");
// Act
var result = await script.RunAsync(fileSkill, null, null, CancellationToken.None);
// Assert
Assert.True(runnerCalled);
Assert.Equal("executed", result);
}
[Fact]
public async Task RunAsync_RunnerReceivesCorrectArgumentsAsync()
{
// Arrange
AgentFileSkill? capturedSkill = null;
AgentFileSkillScript? capturedScript = null;
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct)
{
capturedSkill = skill;
capturedScript = scriptArg;
return Task.FromResult<object?>(null);
}
var script = CreateScript("capture", "/scripts/capture.py", runnerAsync);
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("owner-skill", "Owner"),
"Content",
"/skills/owner-skill");
// Act
await script.RunAsync(fileSkill, null, null, CancellationToken.None);
// Assert
Assert.Same(fileSkill, capturedSkill);
Assert.Same(script, capturedScript);
}
[Fact]
public void Script_HasCorrectNameAndPath()
{
// Arrange & Act
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
var script = CreateScript("my-script", "/path/to/my-script.py", RunnerAsync);
// Assert
Assert.Equal("my-script", script.Name);
Assert.Equal("/path/to/my-script.py", script.FullPath);
}
[Fact]
public void ParametersSchema_ReturnsExpectedArraySchema()
{
// Arrange
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
var script = CreateScript("my-script", "/path/to/script.py", RunnerAsync);
// Act
var schema = script.ParametersSchema;
// Assert
Assert.NotNull(schema);
var raw = schema!.Value.GetRawText();
Assert.Contains("\"type\":\"array\"", raw);
Assert.Contains("\"items\":{\"type\":\"string\"}", raw);
}
[Fact]
public async Task Content_WithScripts_AppendsPerScriptEntriesAsync()
{
// Arrange
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
var script1 = CreateScript("build", "/scripts/build.sh", RunnerAsync);
var script2 = CreateScript("deploy", "/scripts/deploy.sh", RunnerAsync);
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Original content",
"/skills/my-skill",
scripts: [script1, script2]);
// Act
var content = await fileSkill.GetContentAsync();
// Assert — content starts with original and appends per-script entries
Assert.StartsWith("Original content", content);
Assert.Contains("<available_scripts>", content);
Assert.Contains("<script name=\"build\">", content);
Assert.Contains("<script name=\"deploy\">", content);
Assert.Contains("</available_scripts>", content);
// A scripts-only skill still emits an empty resources peer so the model knows none are available
Assert.Contains("<available_resources />", content);
}
[Fact]
public async Task Content_WithoutResourcesOrScripts_EmitsSelfClosingPeersAsync()
{
// Arrange
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Original content only",
"/skills/my-skill");
// Act
var content = await fileSkill.GetContentAsync();
// Assert — both blocks are always emitted as self-closing elements so the model knows none are available
Assert.StartsWith("Original content only", content);
Assert.Contains("<available_resources />", content);
Assert.Contains("<available_scripts />", content);
}
[Fact]
public async Task Content_WithResources_AppendsResourceEntriesAsync()
{
// Arrange
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Original content",
"/skills/my-skill",
resources: [new AgentInlineSkillResource("reference", "value"), new AgentInlineSkillResource("table", "value")]);
// Act
var content = await fileSkill.GetContentAsync();
// Assert — content starts with original and appends per-resource entries so the model knows what is callable
Assert.StartsWith("Original content", content);
Assert.Contains("<available_resources>", content);
Assert.Contains("<resource name=\"reference\"/>", content);
Assert.Contains("<resource name=\"table\"/>", content);
Assert.Contains("</available_resources>", content);
// A resources-only skill still emits an empty scripts peer so the model knows none are available
Assert.Contains("<available_scripts />", content);
}
[Fact]
public async Task Content_WithResourcesAndScripts_AppendsResourcesBeforeScriptsAsync()
{
// Arrange
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Original content",
"/skills/my-skill",
resources: [new AgentInlineSkillResource("reference", "value")],
scripts: [CreateScript("build", "/scripts/build.sh", RunnerAsync)]);
// Act
var content = await fileSkill.GetContentAsync();
// Assert — resources block precedes scripts block
var resourcesIndex = content.IndexOf("<available_resources>", StringComparison.Ordinal);
var scriptsIndex = content.IndexOf("<available_scripts>", StringComparison.Ordinal);
Assert.True(resourcesIndex >= 0 && scriptsIndex >= 0);
Assert.True(resourcesIndex < scriptsIndex);
}
[Fact]
public async Task Content_WithScripts_IsCachedAsync()
{
// Arrange
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
var script = CreateScript("test", "/scripts/test.sh", RunnerAsync);
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Content",
"/skills/my-skill",
scripts: [script]);
// Act
var content1 = await fileSkill.GetContentAsync();
var content2 = await fileSkill.GetContentAsync();
// Assert
Assert.Same(content1, content2);
}
[Fact]
public async Task RunAsync_ForwardsJsonArrayArgumentsToRunnerAsync()
{
// Arrange
JsonElement? capturedArgs = null;
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct)
{
capturedArgs = args;
return Task.FromResult<object?>("done");
}
var script = CreateScript("array-test", "/scripts/test.sh", runnerAsync);
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Content",
"/skills/my-skill");
using var arrayArgsDoc = JsonDocument.Parse("""["arg1","arg2","arg3"]""");
var arrayArgs = arrayArgsDoc.RootElement;
// Act
await script.RunAsync(fileSkill, arrayArgs, null, CancellationToken.None);
// Assert — the raw JSON array is forwarded unchanged
Assert.NotNull(capturedArgs);
Assert.Equal(JsonValueKind.Array, capturedArgs!.Value.ValueKind);
Assert.Equal("""["arg1","arg2","arg3"]""", capturedArgs.Value.GetRawText());
}
[Fact]
public async Task RunAsync_ForwardsServiceProviderToRunnerAsync()
{
// Arrange
IServiceProvider? capturedProvider = null;
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct)
{
capturedProvider = sp;
return Task.FromResult<object?>("done");
}
var script = CreateScript("sp-test", "/scripts/test.sh", runnerAsync);
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Content",
"/skills/my-skill");
var mockProvider = new TestServiceProvider();
// Act
await script.RunAsync(fileSkill, null, mockProvider, CancellationToken.None);
// Assert
Assert.Same(mockProvider, capturedProvider);
}
[Fact]
public async Task RunAsync_NoRunner_ThrowsInvalidOperationExceptionAsync()
{
// Arrange — create script without a runner
var script = CreateScript("no-runner", "/scripts/test.sh", runner: null);
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Content",
"/skills/my-skill");
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => script.RunAsync(fileSkill, null, null, CancellationToken.None));
}
[Fact]
public async Task Content_WithScripts_ContainsDefaultParametersSchemaAsync()
{
// Arrange
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
var script = CreateScript("test", "/scripts/test.sh", RunnerAsync);
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Original content",
"/skills/my-skill",
scripts: [script]);
// Act
var content = await fileSkill.GetContentAsync();
// Assert — the appended block contains the actual default schema from AgentFileSkillScript
Assert.Contains("""{"type":"array","items":{"type":"string"}}""", content);
}
/// <summary>
/// Helper to create an <see cref="AgentFileSkillScript"/> via reflection since the constructor is internal.
/// </summary>
private static AgentFileSkillScript CreateScript(string name, string fullPath, AgentFileSkillScriptRunner? runner)
{
var ctor = typeof(AgentFileSkillScript).GetConstructor(
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance,
null,
[typeof(string), typeof(string), typeof(AgentFileSkillScriptRunner)],
null) ?? throw new InvalidOperationException("Could not find internal constructor.");
return (AgentFileSkillScript)ctor.Invoke([name, fullPath, runner]);
}
/// <summary>
/// Minimal <see cref="IServiceProvider"/> for testing service forwarding.
/// </summary>
private sealed class TestServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
}
@@ -0,0 +1,289 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for script discovery and execution in <see cref="AgentFileSkillsSource"/>.
/// </summary>
public sealed class AgentFileSkillsSourceScriptTests : IDisposable
{
private static readonly string[] s_rubyExtension = new[] { ".rb" };
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, sp, ct) => Task.FromResult<object?>(null);
private readonly string _testRoot;
public AgentFileSkillsSourceScriptTests()
{
this._testRoot = Path.Combine(Path.GetTempPath(), "skills-source-script-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(this._testRoot);
}
public void Dispose()
{
if (Directory.Exists(this._testRoot))
{
Directory.Delete(this._testRoot, recursive: true);
}
}
[Fact]
public async Task GetSkillsAsync_WithScriptFiles_DiscoversScriptsAsync()
{
// Arrange
CreateSkillWithScript(this._testRoot, "my-skill", "A test skill", "Body.", "scripts/convert.py", "print('hello')");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert
Assert.Single(skills);
var skill = skills[0];
var script = await skill.GetScriptAsync("scripts/convert.py");
Assert.NotNull(script);
Assert.Equal("scripts/convert.py", script!.Name);
}
[Fact]
public async Task GetSkillsAsync_WithMultipleScriptExtensions_DiscoversAllAsync()
{
// Arrange
string skillDir = CreateSkillDir(this._testRoot, "multi-ext-skill", "Multi-extension skill", "Body.");
CreateFile(skillDir, "scripts/run.py", "print('py')");
CreateFile(skillDir, "scripts/run.sh", "echo 'sh'");
CreateFile(skillDir, "scripts/run.js", "console.log('js')");
CreateFile(skillDir, "scripts/run.ps1", "Write-Host 'ps'");
CreateFile(skillDir, "scripts/run.cs", "Console.WriteLine();");
CreateFile(skillDir, "scripts/run.csx", "Console.WriteLine();");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert
Assert.Single(skills);
// Assert — verify all expected scripts are discoverable
foreach (var name in (string[])["scripts/run.cs", "scripts/run.csx", "scripts/run.js", "scripts/run.ps1", "scripts/run.py", "scripts/run.sh"])
{
var script = await skills[0].GetScriptAsync(name);
Assert.NotNull(script);
Assert.Equal(name, script!.Name);
}
}
[Fact]
public async Task GetSkillsAsync_NonScriptExtensionsAreNotDiscoveredAsync()
{
// Arrange
string skillDir = CreateSkillDir(this._testRoot, "no-script-skill", "Non-script skill", "Body.");
CreateFile(skillDir, "scripts/data.txt", "text data");
CreateFile(skillDir, "scripts/config.json", "{}");
CreateFile(skillDir, "scripts/notes.md", "# Notes");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert
Assert.Single(skills);
Assert.Null(await skills[0].GetScriptAsync("scripts/data.txt"));
}
[Fact]
public async Task GetSkillsAsync_NoScriptFiles_ReturnsEmptyScriptsAsync()
{
// Arrange
CreateSkillDir(this._testRoot, "no-scripts", "No scripts skill", "Body.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert
Assert.Single(skills);
Assert.Null(await skills[0].GetScriptAsync("any-script"));
}
[Fact]
public async Task GetSkillsAsync_ScriptsInRootAndSubdirectories_AreDiscoveredByDefaultAsync()
{
// Arrange — with default depth=2, scripts in root and immediate subdirectories are discovered
string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body.");
CreateFile(skillDir, "convert.py", "print('root')");
CreateFile(skillDir, "tools/helper.sh", "echo 'helper'");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert — both root and subdirectory scripts are discovered
Assert.Single(skills);
Assert.NotNull(await skills[0].GetScriptAsync("convert.py"));
Assert.NotNull(await skills[0].GetScriptAsync("tools/helper.sh"));
}
[Fact]
public async Task GetSkillsAsync_WithRunner_ScriptsCanRunAsync()
{
// Arrange
CreateSkillWithScript(this._testRoot, "exec-skill", "Executor test", "Body.", "scripts/test.py", "print('ok')");
var executorCalled = false;
var source = new AgentFileSkillsSource(
this._testRoot,
(skill, script, args, sp, ct) =>
{
executorCalled = true;
Assert.Equal("exec-skill", skill.Frontmatter.Name);
Assert.Equal("scripts/test.py", script.Name);
Assert.Equal(Path.GetFullPath(Path.Combine(this._testRoot, "exec-skill", "scripts", "test.py")), script.FullPath);
return Task.FromResult<object?>("executed");
});
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
var scriptResult = await (await skills[0].GetScriptAsync("scripts/test.py"))!.RunAsync(skills[0], null, null, CancellationToken.None);
// Assert
Assert.True(executorCalled);
Assert.Equal("executed", scriptResult);
}
[Fact]
public void Constructor_NullExecutor_DoesNotThrow()
{
// Arrange & Act & Assert — null runner is allowed when skills have no scripts
var source = new AgentFileSkillsSource(this._testRoot, null);
Assert.NotNull(source);
}
[Fact]
public async Task GetSkillsAsync_ScriptsWithNoRunner_ThrowsOnRunAsync()
{
// Arrange
string skillDir = CreateSkillDir(this._testRoot, "no-runner-skill", "No runner", "Body.");
CreateFile(skillDir, "scripts/run.sh", "echo 'hello'");
var source = new AgentFileSkillsSource(this._testRoot, scriptRunner: null);
// Act — discovery succeeds even without a runner
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
var script = (await skills[0].GetScriptAsync("scripts/run.sh"))!;
// Assert — running the script throws because no runner was provided
await Assert.ThrowsAsync<InvalidOperationException>(() => script.RunAsync(skills[0], null, null, CancellationToken.None));
}
[Fact]
public async Task GetSkillsAsync_CustomScriptExtensions_OnlyDiscoversMatchingAsync()
{
// Arrange
string skillDir = CreateSkillDir(this._testRoot, "custom-ext-skill", "Custom extensions", "Body.");
CreateFile(skillDir, "scripts/run.py", "print('py')");
CreateFile(skillDir, "scripts/run.rb", "puts 'rb'");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedScriptExtensions = s_rubyExtension });
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert
Assert.Single(skills);
var rbScript = await skills[0].GetScriptAsync("scripts/run.rb");
Assert.NotNull(rbScript);
Assert.Equal("scripts/run.rb", rbScript!.Name);
}
[Fact]
public async Task GetSkillsAsync_ExecutorReceivesArgumentsAsync()
{
// Arrange
CreateSkillWithScript(this._testRoot, "args-skill", "Args test", "Body.", "scripts/test.py", "print('ok')");
JsonElement? capturedArgs = null;
var source = new AgentFileSkillsSource(
this._testRoot,
(skill, script, args, sp, ct) =>
{
capturedArgs = args;
return Task.FromResult<object?>("done");
});
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
using var argumentsDoc = JsonDocument.Parse("""{"value":26.2,"factor":1.60934}""");
var arguments = argumentsDoc.RootElement;
await (await skills[0].GetScriptAsync("scripts/test.py"))!.RunAsync(skills[0], arguments, null, CancellationToken.None);
// Assert
Assert.NotNull(capturedArgs);
Assert.Equal(JsonValueKind.Object, capturedArgs!.Value.ValueKind);
Assert.Equal(26.2, capturedArgs.Value.GetProperty("value").GetDouble());
Assert.Equal(1.60934, capturedArgs.Value.GetProperty("factor").GetDouble());
}
[Fact]
public async Task GetSkillsAsync_DeepScript_DiscoveredWithHigherDepthAsync()
{
// Arrange — script at depth 4 (f1/f2/f3/run.py) discovered with SearchDepth=5
string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script directory", "Body.");
CreateFile(skillDir, "f1/f2/f3/run.py", "print('nested')");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { SearchDepth = 5 });
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert — script file inside the deeply nested directory is discovered
Assert.Single(skills);
var nestedScript = await skills[0].GetScriptAsync("f1/f2/f3/run.py");
Assert.NotNull(nestedScript);
Assert.Equal("f1/f2/f3/run.py", nestedScript!.Name);
}
[Fact]
public async Task GetSkillsAsync_ScriptFilter_ExcludesFilteredScriptsAsync()
{
// Arrange — ScriptFilter excludes scripts in the "f2" subdirectory
string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Filter test", "Body.");
CreateFile(skillDir, "scripts/run.py", "print('scripts')");
CreateFile(skillDir, "f2/run.py", "print('f2')");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptFilter = ctx => !ctx.RelativeFilePath.StartsWith("f2/", StringComparison.OrdinalIgnoreCase) });
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert — only scripts/ script is included; f2/ is excluded by filter
Assert.Single(skills);
var script = await skills[0].GetScriptAsync("scripts/run.py");
Assert.NotNull(script);
Assert.Equal("scripts/run.py", script!.Name);
Assert.Null(await skills[0].GetScriptAsync("f2/run.py"));
}
private static string CreateSkillDir(string root, string name, string description, string body)
{
string skillDir = Path.Combine(root, name);
Directory.CreateDirectory(skillDir);
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
$"---\nname: {name}\ndescription: {description}\n---\n{body}");
return skillDir;
}
private static void CreateSkillWithScript(string root, string name, string description, string body, string scriptRelativePath, string scriptContent)
{
string skillDir = CreateSkillDir(root, name, description, body);
CreateFile(skillDir, scriptRelativePath, scriptContent);
}
private static void CreateFile(string root, string relativePath, string content)
{
string fullPath = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
File.WriteAllText(fullPath, content);
}
}
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentInMemorySkillsSource"/>.
/// </summary>
public sealed class AgentInMemorySkillsSourceTests
{
[Fact]
public async Task GetSkillsAsync_ValidSkills_ReturnsAllAsync()
{
// Arrange
var skills = new AgentSkill[]
{
new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."),
new AgentInlineSkill("another", "Another valid skill.", "More instructions."),
};
var source = new AgentInMemorySkillsSource(skills);
// Act
var result = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("my-skill", result[0].Frontmatter.Name);
Assert.Equal("another", result[1].Frontmatter.Name);
}
[Theory]
[InlineData("INVALID-NAME")]
[InlineData("-leading")]
[InlineData("trailing-")]
public void Constructor_InvalidFrontmatter_ThrowsArgumentException(string invalidName)
{
// Act & Assert
Assert.Throws<ArgumentException>(() =>
new AgentInlineSkill(invalidName, "A skill.", "Instructions."));
}
[Fact]
public void Constructor_NullSkills_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new AgentInMemorySkillsSource(null!));
}
}
@@ -0,0 +1,348 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentInlineSkillContentBuilder"/>, focusing on the structure of the
/// emitted <c>&lt;available_resources&gt;</c> and <c>&lt;available_scripts&gt;</c> blocks.
/// </summary>
public sealed class AgentInlineSkillContentBuilderTests
{
[Fact]
public void Build_NullResourcesAndScripts_EmitsSelfClosingTags()
{
// Act
var content = AgentInlineSkillContentBuilder.Build("my-skill", "A skill.", "Instructions.", resources: null, scripts: null);
// Assert — explicit empty elements signal "none available" so the model does not hallucinate names
Assert.Contains("<available_resources />", content);
Assert.Contains("<available_scripts />", content);
Assert.DoesNotContain("<available_resources>", content);
Assert.DoesNotContain("<available_scripts>", content);
}
[Fact]
public void Build_EmptyResourcesAndScripts_EmitsSelfClosingTags()
{
// Act
var content = AgentInlineSkillContentBuilder.Build(
"my-skill",
"A skill.",
"Instructions.",
resources: Array.Empty<AgentSkillResource>(),
scripts: Array.Empty<AgentSkillScript>());
// Assert
Assert.Contains("<available_resources />", content);
Assert.Contains("<available_scripts />", content);
}
[Fact]
public void Build_ResourcesOnly_EmitsResourceEntriesAndSelfClosingScripts()
{
// Arrange
var resources = new AgentSkillResource[]
{
new AgentInlineSkillResource("config", "value", "A described resource."),
new AgentInlineSkillResource("table", "value"),
};
// Act
var content = AgentInlineSkillContentBuilder.Build("my-skill", "A skill.", "Instructions.", resources, scripts: null);
// Assert — resources are listed by name with optional description, scripts are an empty element
Assert.Contains("<available_resources>", content);
Assert.Contains("<resource name=\"config\" description=\"A described resource.\"/>", content);
Assert.Contains("<resource name=\"table\"/>", content);
Assert.Contains("</available_resources>", content);
Assert.Contains("<available_scripts />", content);
}
[Fact]
public void Build_ScriptsOnly_EmitsSelfClosingResourcesAndScriptsBlock()
{
// Arrange
var scripts = new AgentSkillScript[] { new FakeScript("run", ParseSchema("{\"type\":\"object\"}")) };
// Act
var content = AgentInlineSkillContentBuilder.Build("my-skill", "A skill.", "Instructions.", resources: null, scripts);
// Assert
Assert.Contains("<available_resources />", content);
Assert.Contains("<available_scripts>", content);
Assert.Contains("<script name=\"run\">", content);
Assert.Contains("</available_scripts>", content);
}
[Fact]
public void Build_MultipleResources_RendersAllInRegistrationOrder()
{
// Arrange
var resources = new AgentSkillResource[]
{
new AgentInlineSkillResource("first", "v"),
new AgentInlineSkillResource("second", "v"),
new AgentInlineSkillResource("third", "v"),
};
// Act
var content = AgentInlineSkillContentBuilder.Build("my-skill", "A skill.", "Instructions.", resources, scripts: null);
// Assert — order is preserved
var firstIndex = content.IndexOf("first", StringComparison.Ordinal);
var secondIndex = content.IndexOf("second", StringComparison.Ordinal);
var thirdIndex = content.IndexOf("third", StringComparison.Ordinal);
Assert.True(firstIndex < secondIndex && secondIndex < thirdIndex);
}
[Fact]
public void Build_ResourceNameWithSpecialCharacters_IsXmlEscaped()
{
// Arrange
var resources = new AgentSkillResource[] { new AgentInlineSkillResource("a<b>&\"c", "v") };
// Act
var content = AgentInlineSkillContentBuilder.Build("my-skill", "A skill.", "Instructions.", resources, scripts: null);
// Assert — XML special characters in the name are escaped
Assert.Contains("<resource name=\"a&lt;b&gt;&amp;&quot;c\"/>", content);
}
[Fact]
public void BuildAvailableResourcesBlock_EmptyList_ReturnsSelfClosingElement()
{
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableResourcesBlock(Array.Empty<AgentSkillResource>());
// Assert — empty list yields a self-closing element so the model knows none are available
Assert.Equal("\n<available_resources />", block);
}
[Fact]
public void BuildAvailableResourcesBlock_WithResources_EmitsDescriptionWhenPresent()
{
// Arrange
var resources = new AgentSkillResource[]
{
new AgentInlineSkillResource("config", "value", "A described resource."),
new AgentInlineSkillResource("table", "value"),
};
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableResourcesBlock(resources);
// Assert — resources with description include it as an attribute; those without omit it
Assert.Contains("<available_resources>", block);
Assert.Contains("<resource name=\"config\" description=\"A described resource.\"/>", block);
Assert.Contains("<resource name=\"table\"/>", block);
Assert.Contains("</available_resources>", block);
}
[Fact]
public void BuildAvailableResourcesBlock_ResourceDescriptionWithSpecialCharacters_IsXmlEscaped()
{
// Arrange
var resources = new AgentSkillResource[] { new AgentInlineSkillResource("cfg", "v", "has <special> & \"chars\"") };
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableResourcesBlock(resources);
// Assert
Assert.Contains("description=\"has &lt;special&gt; &amp; &quot;chars&quot;\"", block);
}
[Fact]
public void BuildAvailableResourcesBlock_ResourceWithEmptyDescription_OmitsDescriptionAttribute()
{
// Arrange
var resources = new AgentSkillResource[] { new AgentInlineSkillResource("config", "value", string.Empty) };
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableResourcesBlock(resources);
// Assert
Assert.Contains("<resource name=\"config\"/>", block);
Assert.DoesNotContain("description=", block);
}
[Fact]
public void BuildAvailableResourcesBlock_ResourceWithWhitespaceDescription_OmitsDescriptionAttribute()
{
// Arrange
var resources = new AgentSkillResource[] { new AgentInlineSkillResource("config", "value", " ") };
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableResourcesBlock(resources);
// Assert
Assert.Contains("<resource name=\"config\"/>", block);
Assert.DoesNotContain("description=", block);
}
[Fact]
public void BuildAvailableResourcesBlock_ResourceNameWithSpecialCharacters_IsXmlEscaped()
{
// Arrange
var resources = new AgentSkillResource[] { new AgentInlineSkillResource("a<b>&\"c", "v") };
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableResourcesBlock(resources);
// Assert
Assert.Contains("<resource name=\"a&lt;b&gt;&amp;&quot;c\"/>", block);
}
[Fact]
public void BuildAvailableResourcesBlock_NullResources_Throws() =>
Assert.Throws<ArgumentNullException>(() => AgentInlineSkillContentBuilder.BuildAvailableResourcesBlock(null!));
[Fact]
public void BuildAvailableScriptsBlock_EmptyList_ReturnsSelfClosingElement()
{
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(Array.Empty<AgentSkillScript>());
// Assert — empty list yields a self-closing element so the model knows none are available
Assert.Equal("\n<available_scripts />", block);
}
[Fact]
public void BuildAvailableScriptsBlock_ScriptWithoutSchema_UsesSelfClosingScript()
{
// Arrange — a script whose ParametersSchema is null and has no description
var scripts = new AgentSkillScript[] { new FakeScript("no-params", parametersSchema: null) };
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(scripts);
// Assert — self-closing <script> element with no nested parameters_schema
Assert.Contains("<script name=\"no-params\"/>", block);
Assert.DoesNotContain("<parameters_schema>", block);
}
[Fact]
public void BuildAvailableScriptsBlock_ScriptWithDescription_EmitsDescriptionAttribute()
{
// Arrange — a script with a description but no schema
var scripts = new AgentSkillScript[] { new FakeScript("deploy", parametersSchema: null, description: "Deploy the app.") };
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(scripts);
// Assert — description attribute is included
Assert.Contains("<script name=\"deploy\" description=\"Deploy the app.\"/>", block);
}
[Fact]
public void BuildAvailableScriptsBlock_ScriptDescriptionWithSpecialCharacters_IsXmlEscaped()
{
// Arrange
var scripts = new AgentSkillScript[] { new FakeScript("deploy", parametersSchema: null, description: "has <special> & \"chars\"") };
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(scripts);
// Assert
Assert.Contains("description=\"has &lt;special&gt; &amp; &quot;chars&quot;\"", block);
}
[Fact]
public void BuildAvailableScriptsBlock_ScriptWithEmptyDescription_OmitsDescriptionAttribute()
{
// Arrange
var scripts = new AgentSkillScript[] { new FakeScript("deploy", parametersSchema: null, description: string.Empty) };
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(scripts);
// Assert
Assert.Contains("<script name=\"deploy\"/>", block);
Assert.DoesNotContain("description=", block);
}
[Fact]
public void BuildAvailableScriptsBlock_ScriptWithWhitespaceDescription_OmitsDescriptionAttribute()
{
// Arrange
var scripts = new AgentSkillScript[] { new FakeScript("deploy", parametersSchema: null, description: " ") };
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(scripts);
// Assert
Assert.Contains("<script name=\"deploy\"/>", block);
Assert.DoesNotContain("description=", block);
}
[Fact]
public void BuildAvailableScriptsBlock_ScriptWithDescriptionAndSchema_EmitsDescriptionAttribute()
{
// Arrange — a script with both description and schema
var scripts = new AgentSkillScript[] { new FakeScript("search", ParseSchema("{\"type\":\"object\"}"), description: "Search something.") };
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(scripts);
// Assert — description attribute is on the opening tag, schema is nested
Assert.Contains("<script name=\"search\" description=\"Search something.\">", block);
Assert.Contains("<parameters_schema>", block);
}
[Fact]
public void BuildAvailableScriptsBlock_ScriptWithSchema_WrapsSchemaInParametersSchemaElement()
{
// Arrange
var scripts = new AgentSkillScript[] { new FakeScript("search", ParseSchema("{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"}}}")) };
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(scripts);
// Assert — schema is wrapped in <parameters_schema> with preserved quotes (no CDATA)
Assert.Contains("<script name=\"search\">", block);
Assert.Contains("<parameters_schema>", block);
Assert.Contains("\"query\"", block);
Assert.Contains("</parameters_schema>", block);
Assert.Contains("</script>", block);
Assert.DoesNotContain("<![CDATA[", block);
}
[Fact]
public void BuildAvailableScriptsBlock_ScriptNameWithSpecialCharacters_IsXmlEscaped()
{
// Arrange
var scripts = new AgentSkillScript[] { new FakeScript("a<b>&\"c", parametersSchema: null) };
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(scripts);
// Assert
Assert.Contains("<script name=\"a&lt;b&gt;&amp;&quot;c\"/>", block);
}
[Fact]
public void BuildAvailableScriptsBlock_NullScripts_Throws() =>
Assert.Throws<ArgumentNullException>(() => AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(null!));
private static JsonElement ParseSchema(string json) => JsonDocument.Parse(json).RootElement.Clone();
private sealed class FakeScript : AgentSkillScript
{
private readonly JsonElement? _parametersSchema;
public FakeScript(string name, JsonElement? parametersSchema, string? description = null)
: base(name, description)
{
this._parametersSchema = parametersSchema;
}
public override JsonElement? ParametersSchema => this._parametersSchema;
public override Task<object?> RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) =>
Task.FromResult<object?>(null);
}
}
@@ -0,0 +1,225 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentInlineSkillResource"/>.
/// </summary>
public sealed class AgentInlineSkillResourceTests
{
[Fact]
public async Task ReadAsync_StaticValue_ReturnsValueAsync()
{
// Arrange
var resource = new AgentInlineSkillResource("config", "my-value");
// Act
var result = await resource.ReadAsync();
// Assert
Assert.Equal("my-value", result);
}
[Fact]
public async Task ReadAsync_StaticObjectValue_ReturnsSameInstanceAsync()
{
// Arrange
var obj = new object();
var resource = new AgentInlineSkillResource("ref", obj);
// Act
var result = await resource.ReadAsync();
// Assert
Assert.Same(obj, result);
}
[Fact]
public async Task ReadAsync_Delegate_InvokesFunctionAsync()
{
// Arrange
int callCount = 0;
var resource = new AgentInlineSkillResource("dynamic", () =>
{
callCount++;
return "computed";
});
// Act
var result = await resource.ReadAsync();
// Assert
Assert.Equal("computed", result?.ToString());
Assert.Equal(1, callCount);
}
[Fact]
public async Task ReadAsync_Delegate_InvokesEachTimeAsync()
{
// Arrange
int callCount = 0;
var resource = new AgentInlineSkillResource("counter", () => ++callCount);
// Act
await resource.ReadAsync();
await resource.ReadAsync();
var result = await resource.ReadAsync();
// Assert
Assert.Equal(3, callCount);
}
[Fact]
public void Constructor_StaticValue_SetsNameAndDescription()
{
// Arrange & Act
var resource = new AgentInlineSkillResource("my-res", "val", "A description.");
// Assert
Assert.Equal("my-res", resource.Name);
Assert.Equal("A description.", resource.Description);
}
[Fact]
public void Constructor_StaticValue_NullDescription_DescriptionIsNull()
{
// Arrange & Act
var resource = new AgentInlineSkillResource("my-res", "val");
// Assert
Assert.Null(resource.Description);
}
[Fact]
public void Constructor_StaticValue_NullValue_Throws()
{
// Act & Assert — cast needed to target the object overload
#pragma warning disable IDE0004
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillResource("my-res", (object)null!));
#pragma warning restore IDE0004
}
[Fact]
public void Constructor_Delegate_NullMethod_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillResource("my-res", null!));
}
[Fact]
public void Constructor_NullName_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillResource(null!, "val"));
}
[Fact]
public void Constructor_WhitespaceName_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() =>
new AgentInlineSkillResource(" ", "val"));
}
[Fact]
public void Constructor_Delegate_SetsNameAndDescription()
{
// Arrange & Act
var resource = new AgentInlineSkillResource("dyn-res", () => "hello", "Dynamic resource.");
// Assert
Assert.Equal("dyn-res", resource.Name);
Assert.Equal("Dynamic resource.", resource.Description);
}
[Fact]
public async Task ReadAsync_WithSerializerOptions_SerializesReturnCustomTypeAsync()
{
// Arrange — delegate resource returns a custom type; the JSO includes a source-generated context for it
var jso = SkillTestJsonContext.Default.Options;
var resource = new AgentInlineSkillResource("config", () => new SkillConfig { Theme = "dark", Verbose = true }, serializerOptions: jso);
// Act
var result = await resource.ReadAsync();
// Assert — the custom type was returned successfully
Assert.NotNull(result);
Assert.Contains("dark", result!.ToString()!);
}
[Fact]
public async Task ReadAsync_SupportsCancellationTokenAsync()
{
// Arrange
using var cts = new CancellationTokenSource();
var resource = new AgentInlineSkillResource("cancellable", "value");
// Act — should not throw with a non-cancelled token
var result = await resource.ReadAsync(cancellationToken: cts.Token);
// Assert
Assert.Equal("value", result);
}
[Fact]
public void Constructor_MethodInfo_SetsNameAndDescription()
{
// Arrange
var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(StaticResourceHelper), BindingFlags.NonPublic | BindingFlags.Static)!;
// Act
var resource = new AgentInlineSkillResource("method-resource", method, target: null, description: "A method resource.");
// Assert
Assert.Equal("method-resource", resource.Name);
Assert.Equal("A method resource.", resource.Description);
}
[Fact]
public async Task ReadAsync_MethodInfo_StaticMethod_ReturnsValueAsync()
{
// Arrange
var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(StaticResourceHelper), BindingFlags.NonPublic | BindingFlags.Static)!;
var resource = new AgentInlineSkillResource("static-method-res", method, target: null);
// Act
var result = await resource.ReadAsync();
// Assert
Assert.Equal("static-resource-value", result?.ToString());
}
[Fact]
public async Task ReadAsync_MethodInfo_InstanceMethod_ReturnsValueAsync()
{
// Arrange
var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(InstanceResourceHelper), BindingFlags.NonPublic | BindingFlags.Instance)!;
var resource = new AgentInlineSkillResource("instance-method-res", method, target: this);
// Act
var result = await resource.ReadAsync();
// Assert
Assert.Equal("instance-resource-value", result?.ToString());
}
[Fact]
public void Constructor_MethodInfo_NullMethod_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillResource("my-res", null!, target: null));
}
private static string StaticResourceHelper() => "static-resource-value";
private string InstanceResourceHelper() => "instance-resource-value";
}
@@ -0,0 +1,289 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentInlineSkillScript"/>.
/// </summary>
public sealed class AgentInlineSkillScriptTests
{
[Fact]
public async Task RunAsync_InvokesDelegate_ReturnsResultAsync()
{
// Arrange
var script = new AgentInlineSkillScript("greet", () => "hello");
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
// Act
var result = await script.RunAsync(skill, null, null, CancellationToken.None);
// Assert
Assert.Equal("hello", result?.ToString());
}
[Fact]
public async Task RunAsync_WithParameters_PassesArgumentsAsync()
{
// Arrange
var script = new AgentInlineSkillScript("add", (int a, int b) => a + b);
var skill = new AgentInlineSkill("calc-skill", "Calc.", "Instructions.");
using var argsDoc = JsonDocument.Parse("""{"a":3,"b":7}""");
var args = argsDoc.RootElement;
// Act
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
// Assert
Assert.Equal(10, int.Parse(result?.ToString()!));
}
[Fact]
public void ParametersSchema_NoParameters_ReturnsSchema()
{
// Arrange
var script = new AgentInlineSkillScript("noop", () => "ok");
// Act
var schema = script.ParametersSchema;
// Assert — parameterless delegates still produce a schema
Assert.NotNull(schema);
}
[Fact]
public void ParametersSchema_WithParameters_ContainsPropertyNames()
{
// Arrange
var script = new AgentInlineSkillScript("search", (string query, int limit) => $"{query}:{limit}");
// Act
var schema = script.ParametersSchema;
// Assert
Assert.NotNull(schema);
var schemaText = schema!.Value.GetRawText();
Assert.Contains("query", schemaText);
Assert.Contains("limit", schemaText);
}
[Fact]
public void Constructor_SetsNameAndDescription()
{
// Arrange & Act
var script = new AgentInlineSkillScript("my-script", () => "ok", "Does something.");
// Assert
Assert.Equal("my-script", script.Name);
Assert.Equal("Does something.", script.Description);
}
[Fact]
public void Constructor_NullDescription_DescriptionIsNull()
{
// Arrange & Act
var script = new AgentInlineSkillScript("my-script", () => "ok");
// Assert
Assert.Null(script.Description);
}
[Fact]
public void Constructor_NullName_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillScript(null!, () => "ok"));
}
[Fact]
public void Constructor_WhitespaceName_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() =>
new AgentInlineSkillScript(" ", () => "ok"));
}
[Fact]
public void Constructor_NullMethod_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillScript("my-script", null!));
}
[Fact]
public async Task RunAsync_WithSerializerOptions_MarshalsCustomTypesAsync()
{
// Arrange — script accepts a custom type; the JSO includes a source-generated context for it
var jso = SkillTestJsonContext.Default.Options;
var script = new AgentInlineSkillScript("lookup", (LookupRequest request) => new LookupResponse
{
Items = ["result-1", "result-2"],
TotalCount = request.MaxResults,
}, serializerOptions: jso);
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 5 }, jso);
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
var args = argsDoc.RootElement;
// Act
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
// Assert — the custom input type was deserialized and the response was produced
Assert.NotNull(result);
Assert.Contains("5", result!.ToString()!);
}
[Fact]
public async Task RunAsync_StringParameter_WorksAsync()
{
// Arrange
var script = new AgentInlineSkillScript("echo", (string message) => message);
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
using var argsDoc = JsonDocument.Parse("""{"message":"hello world"}""");
var args = argsDoc.RootElement;
// Act
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
// Assert
Assert.Equal("hello world", result?.ToString());
}
[Fact]
public void Constructor_MethodInfo_SetsNameAndDescription()
{
// Arrange
var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!;
// Act
var script = new AgentInlineSkillScript("method-script", method, target: null, description: "A method script.");
// Assert
Assert.Equal("method-script", script.Name);
Assert.Equal("A method script.", script.Description);
}
[Fact]
public async Task RunAsync_MethodInfo_StaticMethod_InvokesAndReturnsAsync()
{
// Arrange
var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!;
var script = new AgentInlineSkillScript("static-method-script", method, target: null);
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
using var argsDoc = JsonDocument.Parse("""{"input":"hello"}""");
var args = argsDoc.RootElement;
// Act
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
// Assert
Assert.Equal("HELLO", result?.ToString());
}
[Fact]
public async Task RunAsync_MethodInfo_InstanceMethod_InvokesAndReturnsAsync()
{
// Arrange
var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(InstanceScriptHelper), BindingFlags.NonPublic | BindingFlags.Instance)!;
var script = new AgentInlineSkillScript("instance-method-script", method, target: this);
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
using var argsDoc2 = JsonDocument.Parse("""{"input":"test"}""");
var args2 = argsDoc2.RootElement;
// Act
var result = await script.RunAsync(skill, args2, null, CancellationToken.None);
// Assert
Assert.Equal("test-suffix", result?.ToString());
}
[Fact]
public void Constructor_MethodInfo_NullMethod_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillScript("my-script", null!, target: null));
}
[Fact]
public void ParametersSchema_MethodInfo_ContainsParameterNames()
{
// Arrange
var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!;
var script = new AgentInlineSkillScript("param-script", method, target: null);
// Act
var schema = script.ParametersSchema;
// Assert
Assert.NotNull(schema);
Assert.Contains("input", schema!.Value.GetRawText());
}
[Fact]
public async Task RunAsync_WithNonObjectArguments_ThrowsInvalidOperationExceptionAsync()
{
// Arrange — inline scripts require a JSON object for arguments
var script = new AgentInlineSkillScript("noop", () => "ok");
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
using var arrayArgsDoc = JsonDocument.Parse("""["a","b"]""");
var arrayArgs = arrayArgsDoc.RootElement;
// Act & Assert — non-object JSON should fail fast rather than silently dropping arguments
await Assert.ThrowsAsync<InvalidOperationException>(
() => script.RunAsync(skill, arrayArgs, null, CancellationToken.None));
}
[Fact]
public async Task RunAsync_WithNullArguments_TreatsAsNoArgumentsAsync()
{
// Arrange — a parameterless delegate should succeed when given null arguments
var script = new AgentInlineSkillScript("noop", () => "ok");
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
// Act
var result = await script.RunAsync(skill, null, null, CancellationToken.None);
// Assert
Assert.Equal("ok", result?.ToString());
}
[Fact]
public async Task RunAsync_ServiceProviderIsForwardedAsync()
{
// Arrange — delegate that resolves a service from the IServiceProvider
IServiceProvider? capturedProvider = null;
var script = new AgentInlineSkillScript("svc-test", (IServiceProvider sp) =>
{
capturedProvider = sp;
return "done";
});
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
var mockProvider = new TestServiceProvider();
// Act
await script.RunAsync(skill, null, mockProvider, CancellationToken.None);
// Assert
Assert.Same(mockProvider, capturedProvider);
}
private static string StaticScriptHelper(string input) => input.ToUpperInvariant();
private string InstanceScriptHelper(string input) => input + "-suffix";
/// <summary>
/// Minimal <see cref="IServiceProvider"/> for testing service forwarding.
/// </summary>
private sealed class TestServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
}
@@ -0,0 +1,593 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentInlineSkill"/>.
/// </summary>
public sealed class AgentInlineSkillTests
{
[Fact]
public void Constructor_WithNameAndDescription_SetsFrontmatter()
{
// Arrange & Act
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Assert
Assert.Equal("my-skill", skill.Frontmatter.Name);
Assert.Equal("A valid skill.", skill.Frontmatter.Description);
Assert.Null(skill.Frontmatter.License);
Assert.Null(skill.Frontmatter.Compatibility);
Assert.Null(skill.Frontmatter.AllowedTools);
Assert.Null(skill.Frontmatter.Metadata);
}
[Fact]
public void Constructor_WithAllProps_SetsFrontmatter()
{
// Arrange
var metadata = new AdditionalPropertiesDictionary { ["key"] = "value" };
// Act
var skill = new AgentInlineSkill(
"my-skill",
"A valid skill.",
"Instructions.",
license: "MIT",
compatibility: "gpt-4",
allowedTools: "tool-a tool-b",
metadata: metadata);
// Assert
Assert.Equal("my-skill", skill.Frontmatter.Name);
Assert.Equal("A valid skill.", skill.Frontmatter.Description);
Assert.Equal("MIT", skill.Frontmatter.License);
Assert.Equal("gpt-4", skill.Frontmatter.Compatibility);
Assert.Equal("tool-a tool-b", skill.Frontmatter.AllowedTools);
Assert.NotNull(skill.Frontmatter.Metadata);
Assert.Equal("value", skill.Frontmatter.Metadata["key"]);
}
[Fact]
public void Constructor_WithFrontmatter_UsesFrontmatterDirectly()
{
// Arrange
var frontmatter = new AgentSkillFrontmatter("my-skill", "A valid skill.")
{
License = "Apache-2.0",
Compatibility = "gpt-4",
AllowedTools = "tool-a",
Metadata = new AdditionalPropertiesDictionary { ["env"] = "prod" },
};
// Act
var skill = new AgentInlineSkill(frontmatter, "Instructions.");
// Assert
Assert.Same(frontmatter, skill.Frontmatter);
Assert.Equal("Apache-2.0", skill.Frontmatter.License);
Assert.Equal("gpt-4", skill.Frontmatter.Compatibility);
Assert.Equal("tool-a", skill.Frontmatter.AllowedTools);
Assert.Equal("prod", skill.Frontmatter.Metadata!["env"]);
}
[Fact]
public void Constructor_WithFrontmatter_NullFrontmatter_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkill(null!, "Instructions."));
}
[Fact]
public void Constructor_WithFrontmatter_NullInstructions_Throws()
{
// Arrange
var frontmatter = new AgentSkillFrontmatter("my-skill", "A valid skill.");
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkill(frontmatter, null!));
}
[Fact]
public void Constructor_WithAllProps_NullInstructions_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkill("my-skill", "A valid skill.", null!));
}
[Fact]
public async Task Content_ContainsNameDescriptionAndInstructionsAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Do the thing.");
// Act
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<name>my-skill</name>", content);
Assert.Contains("<description>A valid skill.</description>", content);
Assert.Contains("<instructions>\nDo the thing.\n</instructions>", content);
}
[Fact]
public async Task Content_EscapesXmlCharactersAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "x<y>z\"w & it's more", "1 & 2 < 3");
// Act
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<name>my-skill</name>", content);
Assert.Contains("<description>x&lt;y&gt;z&quot;w &amp; it&apos;s more</description>", content);
Assert.Contains("1 &amp; 2 &lt; 3", content); // instructions are escaped
}
[Fact]
public async Task Content_IsCachedAcrossAccessesAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var first = await skill.GetContentAsync();
var second = await skill.GetContentAsync();
// Assert
Assert.Same(first, second);
}
[Fact]
public async Task Content_IncludesResourcesInBodyAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("config", "value1", "A config resource.");
// Act
var content = await skill.GetContentAsync();
// Assert — resources are rendered in the body so the model can discover them
Assert.Contains("<available_resources>", content);
Assert.Contains("<resource name=\"config\" description=\"A config resource.\"/>", content);
}
[Fact]
public async Task Content_IncludesDelegateResourcesInBodyAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("dynamic", () => "hello");
// Act
var content = await skill.GetContentAsync();
// Assert — resources are rendered in the body
Assert.Contains("<available_resources>", content);
Assert.Contains("<resource name=\"dynamic\"/>", content);
}
[Fact]
public async Task Content_IncludesScriptsAddedBeforeFirstAccessAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("run", () => "result", "Runs something.");
// Act
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<available_scripts>", content);
Assert.Contains("<script name=\"run\"", content);
}
[Fact]
public async Task Content_IsCachedAndNotRebuiltAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("r1", "v1");
// Act
var first = await skill.GetContentAsync();
var second = await skill.GetContentAsync();
// Assert
Assert.Same(first, second);
}
[Fact]
public async Task Content_IncludesScriptSchemasAddedBeforeFirstAccessAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("r1", "v1");
skill.AddScript("s1", () => "ok");
// Act
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<available_resources>", content);
Assert.Contains("r1", content);
Assert.Contains("<available_scripts>", content);
Assert.Contains("s1", content);
}
[Fact]
public async Task Content_ParametersSchema_IsXmlEscapedAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("search", (string query, int limit) => $"found {limit} results for {query}");
// Act
var content = await skill.GetContentAsync();
// Assert — JSON schema should be present inside <parameters_schema> element with preserved quotes
Assert.Contains("<script name=\"search\">", content);
Assert.Contains("<parameters_schema>", content);
Assert.Contains("\"query\"", content);
Assert.DoesNotContain("<![CDATA[", content);
}
[Fact]
public void AddResource_NullValue_Throws()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert — cast needed to target the object overload
#pragma warning disable IDE0004
Assert.Throws<ArgumentNullException>(() => skill.AddResource("config", (object)null!));
#pragma warning restore IDE0004
}
[Fact]
public void AddResource_NullDelegate_Throws()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert
Assert.Throws<ArgumentNullException>(() => skill.AddResource("config", null!));
}
[Fact]
public void AddScript_NullDelegate_Throws()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert
Assert.Throws<ArgumentNullException>(() => skill.AddScript("run", null!));
}
[Fact]
public void Resources_WhenNoneAdded_ReturnsNull()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert
Assert.Null(skill.GetTestResources());
}
[Fact]
public async Task Scripts_WhenNoneAdded_ReturnsNullAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert
Assert.Null(await skill.GetScriptAsync("nonexistent"));
}
[Fact]
public async Task GetResourceAsync_ExistingName_ReturnsResourceAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("r1", "v1");
skill.AddResource("r2", "v2");
// Act
var resource = await skill.GetResourceAsync("r2");
// Assert
Assert.NotNull(resource);
Assert.Equal("r2", resource!.Name);
}
[Fact]
public async Task GetResourceAsync_NonExistingName_ReturnsNullAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("r1", "v1");
// Act
var resource = await skill.GetResourceAsync("missing");
// Assert
Assert.Null(resource);
}
[Fact]
public async Task GetResourceAsync_NoResourcesAdded_ReturnsNullAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var resource = await skill.GetResourceAsync("missing");
// Assert
Assert.Null(resource);
}
[Fact]
public async Task GetScriptAsync_ExistingName_ReturnsScriptAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("s1", () => "first");
skill.AddScript("s2", () => "second");
// Act
var script = await skill.GetScriptAsync("s2");
// Assert
Assert.NotNull(script);
Assert.Equal("s2", script!.Name);
}
[Fact]
public async Task GetScriptAsync_NonExistingName_ReturnsNullAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("s1", () => "ok");
// Act
var script = await skill.GetScriptAsync("missing");
// Assert
Assert.Null(script);
}
[Fact]
public async Task GetScriptAsync_NoScriptsAdded_ReturnsNullAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var script = await skill.GetScriptAsync("missing");
// Assert
Assert.Null(script);
}
[Fact]
public void AddResource_ReturnsSameInstance_ForChaining()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var returned = skill.AddResource("r1", "v1");
// Assert
Assert.Same(skill, returned);
}
[Fact]
public void AddResource_Delegate_ReturnsSameInstance_ForChaining()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var returned = skill.AddResource("r1", () => "v1");
// Assert
Assert.Same(skill, returned);
}
[Fact]
public void AddScript_ReturnsSameInstance_ForChaining()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var returned = skill.AddScript("s1", () => "ok");
// Assert
Assert.Same(skill, returned);
}
[Fact]
public async Task Content_NoResourcesOrScripts_EmitsSelfClosingTagsAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var content = await skill.GetContentAsync();
// Assert — empty self-closing elements are emitted when no resources or scripts exist
Assert.Contains("<available_resources />", content);
Assert.Contains("<available_scripts />", content);
}
[Fact]
public async Task Content_ResourcesAddedAfterCaching_AreNotIncludedAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
_ = await skill.GetContentAsync(); // trigger caching
skill.AddResource("late-resource", "late-value");
// Act
var content = await skill.GetContentAsync();
// Assert — the late resource should not appear because content was cached
Assert.DoesNotContain("late-resource", content);
}
[Fact]
public async Task Content_ScriptsAddedAfterCaching_AreNotIncludedAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
_ = await skill.GetContentAsync(); // trigger caching
skill.AddScript("late-script", () => "late");
// Act
var content = await skill.GetContentAsync();
// Assert — the late script should not appear because content was cached
Assert.DoesNotContain("late-script", content);
}
[Fact]
public async Task Content_ScriptWithDescription_EmitsDescriptionAttributeAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("my-script", () => "ok", "Runs something.");
// Act
var content = await skill.GetContentAsync();
// Assert — the script description is emitted as an attribute
Assert.Contains("<script name=\"my-script\"", content);
Assert.Contains("description=\"Runs something.\"", content);
}
[Fact]
public async Task Content_ScriptWithoutParametersOrDescription_UsesSelfClosingTagAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("simple", () => "ok");
// Act
var content = await skill.GetContentAsync();
// Assert — parameterless Action delegates still produce a schema, so this
// verifies the script is at least included in the output
Assert.Contains("simple", content);
}
[Fact]
public async Task Content_ResourceWithDescription_RenderedInBodyWithDescriptionAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("with-desc", "value", "A described resource.");
skill.AddResource("no-desc", "value");
// Act
var content = await skill.GetContentAsync();
// Assert — resources with descriptions include the attribute; those without omit it
Assert.Contains("<available_resources>", content);
Assert.Contains("<resource name=\"with-desc\" description=\"A described resource.\"/>", content);
Assert.Contains("<resource name=\"no-desc\"/>", content);
}
[Fact]
public async Task AddScript_SkillLevelSerializerOptions_AppliedToScriptAsync()
{
// Arrange — skill-level JSO with source-generated context for custom types
var jso = SkillTestJsonContext.Default.Options;
var skill = new AgentInlineSkill("jso-skill", "JSO test.", "Instructions.", serializerOptions: jso);
skill.AddScript("lookup", (LookupRequest request) => new LookupResponse
{
Items = [$"result for {request.Query}"],
TotalCount = request.MaxResults,
});
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso);
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
var args = argsDoc.RootElement;
// Act
var result = await (await skill.GetScriptAsync("lookup"))!.RunAsync(skill, args, null, CancellationToken.None);
// Assert — the custom input was deserialized via skill-level JSO and response was produced
Assert.NotNull(result);
Assert.Contains("result for test", result!.ToString()!);
}
[Fact]
public async Task AddScript_PerScriptSerializerOptions_OverridesSkillLevelAsync()
{
// Arrange — skill-level JSO uses snake_case naming; per-script JSO overrides with source-generated context
var skillJso = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
var scriptJso = SkillTestJsonContext.Default.Options;
var skill = new AgentInlineSkill("override-skill", "Override test.", "Instructions.", serializerOptions: skillJso);
skill.AddScript("lookup", (LookupRequest request) => new LookupResponse
{
Items = [$"found {request.Query}"],
TotalCount = request.MaxResults,
}, serializerOptions: scriptJso);
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "override", MaxResults = 7 }, scriptJso);
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
var args = argsDoc.RootElement;
// Act
var result = await (await skill.GetScriptAsync("lookup"))!.RunAsync(skill, args, null, CancellationToken.None);
// Assert — per-script JSO takes effect and custom types are properly marshaled
Assert.NotNull(result);
Assert.Contains("found override", result!.ToString()!);
}
[Fact]
public async Task AddResource_SkillLevelSerializerOptions_AppliedToDelegateResourceAsync()
{
// Arrange — skill-level JSO with source-generated context; delegate resource returns a custom type
var jso = SkillTestJsonContext.Default.Options;
var skill = new AgentInlineSkill("custom-type-resource-skill", "Custom type resource test.", "Instructions.", serializerOptions: jso);
skill.AddResource("config", () => new SkillConfig { Theme = "dark", Verbose = true });
// Act
var result = await skill.GetTestResources()![0].ReadAsync();
// Assert — the custom type was returned successfully via skill-level JSO
Assert.NotNull(result);
Assert.Contains("dark", result!.ToString()!);
}
[Fact]
public async Task AddResource_PerResourceSerializerOptions_OverridesSkillLevelAsync()
{
// Arrange — skill-level JSO uses snake_case naming; per-resource JSO overrides with source-generated context
var skillJso = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
var resourceJso = SkillTestJsonContext.Default.Options;
var skill = new AgentInlineSkill("override-resource-skill", "Override resource test.", "Instructions.", serializerOptions: skillJso);
skill.AddResource("config", () => new SkillConfig { Theme = "dark", Verbose = true }, serializerOptions: resourceJso);
// Act
var result = await skill.GetTestResources()![0].ReadAsync();
// Assert — per-resource JSO takes effect and custom type is properly marshaled
Assert.NotNull(result);
Assert.Contains("dark", result!.ToString()!);
}
}
@@ -0,0 +1,260 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentSkillFrontmatter"/> validation.
/// </summary>
public sealed class AgentSkillFrontmatterValidatorTests
{
[Theory]
[InlineData("my-skill")]
[InlineData("a")]
[InlineData("skill123")]
[InlineData("a1b2c3")]
public void ValidateName_ValidName_ReturnsTrue(string name)
{
// Act
bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason);
// Assert
Assert.True(result);
Assert.Null(reason);
}
[Theory]
[InlineData("-leading-hyphen")]
[InlineData("trailing-hyphen-")]
[InlineData("has spaces")]
[InlineData("UPPERCASE")]
[InlineData("consecutive--hyphens")]
[InlineData("special!chars")]
public void ValidateName_InvalidName_ReturnsFalse(string name)
{
// Act
bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason);
// Assert
Assert.False(result);
Assert.NotNull(reason);
Assert.Contains("name", reason, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ValidateName_NameExceedsMaxLength_ReturnsFalse()
{
// Arrange
string longName = new('a', 65);
// Act
bool result = AgentSkillFrontmatter.ValidateName(longName, out string? reason);
// Assert
Assert.False(result);
Assert.NotNull(reason);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ValidateName_NullOrWhitespace_ReturnsFalse(string? name)
{
// Act
bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason);
// Assert
Assert.False(result);
Assert.NotNull(reason);
}
[Fact]
public void ValidateDescription_ValidDescription_ReturnsTrue()
{
// Act
bool result = AgentSkillFrontmatter.ValidateDescription("A valid description.", out string? reason);
// Assert
Assert.True(result);
Assert.Null(reason);
}
[Fact]
public void ValidateDescription_DescriptionExceedsMaxLength_ReturnsFalse()
{
// Arrange
string longDesc = new('x', 1025);
// Act
bool result = AgentSkillFrontmatter.ValidateDescription(longDesc, out string? reason);
// Assert
Assert.False(result);
Assert.NotNull(reason);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ValidateDescription_NullOrWhitespace_ReturnsFalse(string? description)
{
// Act
bool result = AgentSkillFrontmatter.ValidateDescription(description, out string? reason);
// Assert
Assert.False(result);
Assert.NotNull(reason);
}
[Fact]
public void ValidateCompatibility_Null_ReturnsTrue()
{
// Act
bool result = AgentSkillFrontmatter.ValidateCompatibility(null, out string? reason);
// Assert
Assert.True(result);
Assert.Null(reason);
}
[Fact]
public void ValidateCompatibility_WithinMaxLength_ReturnsTrue()
{
// Arrange
string compatibility = new('x', 500);
// Act
bool result = AgentSkillFrontmatter.ValidateCompatibility(compatibility, out string? reason);
// Assert
Assert.True(result);
Assert.Null(reason);
}
[Fact]
public void ValidateCompatibility_ExceedsMaxLength_ReturnsFalse()
{
// Arrange
string compatibility = new('x', 501);
// Act
bool result = AgentSkillFrontmatter.ValidateCompatibility(compatibility, out string? reason);
// Assert
Assert.False(result);
Assert.NotNull(reason);
}
[Theory]
[InlineData("UPPERCASE")]
[InlineData("-leading")]
[InlineData("trailing-")]
[InlineData("consecutive--hyphens")]
public void Constructor_InvalidName_ThrowsArgumentException(string name)
{
// Act & Assert
var ex = Assert.Throws<ArgumentException>(() => new AgentSkillFrontmatter(name, "A valid description."));
Assert.Contains("name", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Constructor_NameExceedsMaxLength_ThrowsArgumentException()
{
// Arrange
string longName = new('a', 65);
// Act & Assert
Assert.Throws<ArgumentException>(() => new AgentSkillFrontmatter(longName, "A valid description."));
}
[Fact]
public void Constructor_DescriptionExceedsMaxLength_ThrowsArgumentException()
{
// Arrange
string longDesc = new('x', 1025);
// Act & Assert
Assert.Throws<ArgumentException>(() => new AgentSkillFrontmatter("valid-name", longDesc));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Constructor_NullOrWhitespaceName_ThrowsArgumentException(string? name)
{
// Act & Assert
Assert.Throws<ArgumentException>(() => new AgentSkillFrontmatter(name!, "A valid description."));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Constructor_NullOrWhitespaceDescription_ThrowsArgumentException(string? description)
{
// Act & Assert
Assert.Throws<ArgumentException>(() => new AgentSkillFrontmatter("valid-name", description!));
}
[Fact]
public void Compatibility_ExceedsMaxLength_ThrowsArgumentException()
{
// Arrange
var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.");
string longCompatibility = new('x', 501);
// Act & Assert
Assert.Throws<ArgumentException>(() => frontmatter.Compatibility = longCompatibility);
}
[Fact]
public void Compatibility_WithinMaxLength_Succeeds()
{
// Arrange
var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.");
string compatibility = new('x', 500);
// Act
frontmatter.Compatibility = compatibility;
// Assert
Assert.Equal(compatibility, frontmatter.Compatibility);
}
[Fact]
public void Compatibility_Null_Succeeds()
{
// Arrange
var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.");
// Act
frontmatter.Compatibility = null;
// Assert
Assert.Null(frontmatter.Compatibility);
}
[Fact]
public void Constructor_WithCompatibility_SetsValue()
{
// Arrange & Act
var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.", "Requires Python 3.10+");
// Assert
Assert.Equal("Requires Python 3.10+", frontmatter.Compatibility);
}
[Fact]
public void Constructor_CompatibilityExceedsMaxLength_ThrowsArgumentException()
{
// Arrange
string longCompatibility = new('x', 501);
// Act & Assert
Assert.Throws<ArgumentException>(() => new AgentSkillFrontmatter("valid-name", "A valid description.", longCompatibility));
}
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentSkillResourceAttribute"/>.
/// </summary>
public sealed class AgentSkillResourceAttributeTests
{
[Fact]
public void DefaultConstructor_NameIsNull()
{
// Arrange & Act
var attr = new AgentSkillResourceAttribute();
// Assert
Assert.Null(attr.Name);
}
[Fact]
public void NamedConstructor_SetsName()
{
// Arrange & Act
var attr = new AgentSkillResourceAttribute("my-resource");
// Assert
Assert.Equal("my-resource", attr.Name);
}
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentSkillScriptAttribute"/>.
/// </summary>
public sealed class AgentSkillScriptAttributeTests
{
[Fact]
public void DefaultConstructor_NameIsNull()
{
// Arrange & Act
var attr = new AgentSkillScriptAttribute();
// Assert
Assert.Null(attr.Name);
}
[Fact]
public void NamedConstructor_SetsName()
{
// Arrange & Act
var attr = new AgentSkillScriptAttribute("my-script");
// Assert
Assert.Equal("my-script", attr.Name);
}
}
@@ -0,0 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Test-only helpers that peek at the underlying resource list of a skill via reflection.
/// </summary>
/// <remarks>
/// The public <see cref="AgentSkill"/> API exposes resources only through
/// <see cref="AgentSkill.GetResourceAsync"/>.
/// These helpers exist purely to allow unit tests for <see cref="AgentFileSkill"/> and
/// <see cref="AgentInlineSkill"/> to inspect the concrete enumerated list a skill carries.
/// </remarks>
internal static class AgentSkillTestExtensions
{
public static IReadOnlyList<AgentSkillResource>? GetTestResources(this AgentSkill skill)
{
// AgentFileSkill / AgentInlineSkill: private "_resources" field.
for (var type = skill.GetType(); type is not null; type = type.BaseType)
{
var field = type.GetField("_resources", BindingFlags.NonPublic | BindingFlags.Instance);
if (field is not null)
{
return UnwrapList(field.GetValue(skill));
}
}
return null;
}
private static IReadOnlyList<AgentSkillResource>? UnwrapList(object? value) =>
value switch
{
null => null,
IReadOnlyList<AgentSkillResource> list => list,
IEnumerable<AgentSkillResource> seq => seq.ToList(),
_ => null,
};
}
@@ -0,0 +1,261 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentSkillsProviderBuilder"/>.
/// </summary>
public sealed class AgentSkillsProviderBuilderTests
{
private readonly TestAIAgent _agent = new();
private AIContextProvider.InvokingContext CreateInvokingContext(AIAgent? agent = null)
{
return new AIContextProvider.InvokingContext(agent ?? this._agent, session: null, new AIContext());
}
[Fact]
public void Build_NoSourceConfigured_Succeeds()
{
// Arrange
var builder = new AgentSkillsProviderBuilder();
// Act
var provider = builder.Build();
// Assert
Assert.NotNull(provider);
}
[Fact]
public void Build_WithCustomSource_Succeeds()
{
// Arrange
var source = new TestAgentSkillsSource(
new TestAgentSkill("custom", "Custom skill", "Instructions."));
var builder = new AgentSkillsProviderBuilder()
.UseSource(source);
// Act
var provider = builder.Build();
// Assert
Assert.NotNull(provider);
}
[Fact]
public void UseSource_NullSource_ThrowsArgumentNullException()
{
// Arrange
var builder = new AgentSkillsProviderBuilder();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => builder.UseSource((AgentSkillsSource)null!));
}
[Fact]
public void UseFilter_NullPredicate_ThrowsArgumentNullException()
{
// Arrange
var builder = new AgentSkillsProviderBuilder();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => builder.UseFilter(null!));
}
[Fact]
public void UseFileScriptRunner_NullRunner_ThrowsArgumentNullException()
{
// Arrange
var builder = new AgentSkillsProviderBuilder();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => builder.UseFileScriptRunner(null!));
}
[Fact]
public void UseOptions_NullConfigure_ThrowsArgumentNullException()
{
// Arrange
var builder = new AgentSkillsProviderBuilder();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => builder.UseOptions(null!));
}
[Fact]
public void UseCachingOptions_NullConfigure_ThrowsArgumentNullException()
{
// Arrange
var builder = new AgentSkillsProviderBuilder();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => builder.UseCachingOptions(null!));
}
[Fact]
public async Task Build_WithFilter_AppliesFilterToSkillsAsync()
{
// Arrange
var source = new TestAgentSkillsSource(
new TestAgentSkill("keep-me", "Keep", "Instructions."),
new TestAgentSkill("drop-me", "Drop", "Instructions."));
var provider = new AgentSkillsProviderBuilder()
.UseSource(source)
.UseFilter((skill, context) => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase))
.Build();
// Act
var result = await provider.InvokingAsync(
this.CreateInvokingContext(), CancellationToken.None);
// Assert — the instructions should mention "keep-me" but not "drop-me"
Assert.NotNull(result.Instructions);
Assert.Contains("keep-me", result.Instructions);
Assert.DoesNotContain("drop-me", result.Instructions);
}
[Fact]
public async Task Build_WithCacheEnabled_CachesSkillsAsync()
{
// Arrange
var countingSource = new CountingSource(
new TestAgentSkill("skill-a", "A", "Instructions."));
var provider = new AgentSkillsProviderBuilder()
.UseSource(countingSource)
.Build();
// Act
await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None);
await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None);
// Assert — inner source should only be called once due to caching
Assert.Equal(1, countingSource.CallCount);
}
[Fact]
public async Task Build_WithCacheDisabled_InvokesSourceOnEachCallAsync()
{
// Arrange
var countingSource = new CountingSource(
new TestAgentSkill("skill-a", "A", "Instructions."));
var provider = new AgentSkillsProviderBuilder()
.UseSource(countingSource)
.DisableCaching()
.Build();
// Act
await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None);
await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None);
await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None);
// Assert — without caching, each call should hit the inner source
Assert.Equal(3, countingSource.CallCount);
}
[Fact]
public async Task Build_WithCacheIsolationKey_CachesPerKeyAsync()
{
// Arrange
var agent1 = new TestAIAgent();
var agent2 = new TestAIAgent();
var countingSource = new CountingSource(
new TestAgentSkill("skill-a", "A", "Instructions."));
var provider = new AgentSkillsProviderBuilder()
.UseSource(countingSource)
.UseCachingOptions(options => options.CacheIsolationKeySelector = context => ReferenceEquals(context.Agent, agent1) ? "agent-1" : "agent-2")
.Build();
// Act
await provider.InvokingAsync(this.CreateInvokingContext(agent1), CancellationToken.None);
await provider.InvokingAsync(this.CreateInvokingContext(agent1), CancellationToken.None);
await provider.InvokingAsync(this.CreateInvokingContext(agent2), CancellationToken.None);
// Assert
Assert.Equal(2, countingSource.CallCount);
}
[Fact]
public void Build_FluentChaining_ReturnsSameBuilder()
{
// Arrange
var builder = new AgentSkillsProviderBuilder();
var source = new TestAgentSkillsSource(
new TestAgentSkill("test", "Test", "Instructions."));
// Act — all fluent methods should return the same builder
var result = builder
.UseSource(source)
.UsePromptTemplate("Skills:\n{skills}");
// Assert
Assert.Same(builder, result);
}
[Fact]
public void Build_UseOptions_ConfiguresOptions()
{
// Arrange
var source = new TestAgentSkillsSource(
new TestAgentSkill("test", "Test", "Instructions."));
// Act — UseOptions should not throw and successfully configure
var provider = new AgentSkillsProviderBuilder()
.UseSource(source)
.UseOptions(opts => opts.IncludeDetailedErrors = true)
.Build();
// Assert
Assert.NotNull(provider);
}
[Fact]
public async Task Build_WithMultipleCustomSources_AggregatesAllAsync()
{
// Arrange
var source1 = new TestAgentSkillsSource(
new TestAgentSkill("from-one", "Source 1", "Instructions 1."));
var source2 = new TestAgentSkillsSource(
new TestAgentSkill("from-two", "Source 2", "Instructions 2."));
var provider = new AgentSkillsProviderBuilder()
.UseSource(source1)
.UseSource(source2)
.Build();
// Act
var result = await provider.InvokingAsync(
this.CreateInvokingContext(), CancellationToken.None);
// Assert
Assert.NotNull(result.Instructions);
Assert.Contains("from-one", result.Instructions);
Assert.Contains("from-two", result.Instructions);
}
/// <summary>
/// A test source that counts how many times GetSkillsAsync is called.
/// </summary>
private sealed class CountingSource : AgentSkillsSource
{
private readonly AgentSkill[] _skills;
private int _callCount;
public CountingSource(params AgentSkill[] skills)
{
this._skills = skills;
}
public int CallCount => this._callCount;
public override Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref this._callCount);
return Task.FromResult<IList<AgentSkill>>(this._skills);
}
}
}
@@ -0,0 +1,562 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="CachingAgentSkillsSource"/>.
/// </summary>
public sealed class CachingAgentSkillsSourceTests
{
[Fact]
public async Task GetSkillsAsync_MultipleInvocations_CallsInnerSourceOnceAsync()
{
// Arrange
var inner = new CountingSkillsSource(
[
new AgentInlineSkill("skill-a", "A", "Instructions A."),
]);
var source = new CachingAgentSkillsSource(inner);
var context = TestAgentSkillsSourceContextFactory.Create();
// Act
var result1 = await source.GetSkillsAsync(context, CancellationToken.None);
var result2 = await source.GetSkillsAsync(context, CancellationToken.None);
// Assert
Assert.Same(result1, result2);
Assert.Equal(1, inner.CallCount);
}
[Fact]
public async Task GetSkillsAsync_ConcurrentInvocations_CallsInnerSourceOnceAsync()
{
// Arrange
var inner = new CountingSkillsSource(
[
new AgentInlineSkill("skill-b", "B", "Instructions B."),
]);
var source = new CachingAgentSkillsSource(inner);
var context = TestAgentSkillsSourceContextFactory.Create();
// Act
var tasks = Enumerable.Range(0, 10)
.Select(_ => source.GetSkillsAsync(context, CancellationToken.None))
.ToArray();
var results = await Task.WhenAll(tasks);
// Assert
Assert.Equal(1, inner.CallCount);
foreach (var result in results)
{
Assert.Same(results[0], result);
}
}
[Fact]
public async Task GetSkillsAsync_InnerSourceThrows_DoesNotCacheErrorAsync()
{
// Arrange
var inner = new FailingSkillsSource(failOnFirstCall: true);
var source = new CachingAgentSkillsSource(inner);
var context = TestAgentSkillsSourceContextFactory.Create();
// Act — first call fails
await Assert.ThrowsAsync<InvalidOperationException>(() => source.GetSkillsAsync(context, CancellationToken.None));
// Act — second call succeeds (cache was cleared)
var result = await source.GetSkillsAsync(context, CancellationToken.None);
// Assert
Assert.Single(result);
Assert.Equal(2, inner.CallCount);
}
[Fact]
public async Task GetSkillsAsync_ReturnsResultFromInnerSourceAsync()
{
// Arrange
var skills = new AgentSkill[]
{
new AgentInlineSkill("skill-x", "X", "Body X."),
new AgentInlineSkill("skill-y", "Y", "Body Y."),
};
var inner = new CountingSkillsSource(skills);
var source = new CachingAgentSkillsSource(inner);
var context = TestAgentSkillsSourceContextFactory.Create();
// Act
var result = await source.GetSkillsAsync(context, CancellationToken.None);
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("skill-x", result[0].Frontmatter.Name);
Assert.Equal("skill-y", result[1].Frontmatter.Name);
}
[Fact]
public async Task GetSkillsAsync_SameIsolationKey_CallsInnerSourceOnceAsync()
{
// Arrange
var inner = new CountingSkillsSource(
[
new AgentInlineSkill("skill-a", "A", "Instructions A."),
]);
var source = new CachingAgentSkillsSource(
inner,
new CachingAgentSkillsSourceOptions { CacheIsolationKeySelector = _ => "same-key" });
var context1 = TestAgentSkillsSourceContextFactory.Create();
var context2 = TestAgentSkillsSourceContextFactory.Create();
// Act
var result1 = await source.GetSkillsAsync(context1, CancellationToken.None);
var result2 = await source.GetSkillsAsync(context2, CancellationToken.None);
// Assert
Assert.Same(result1, result2);
Assert.Equal(1, inner.CallCount);
}
[Fact]
public async Task GetSkillsAsync_DifferentIsolationKeys_CachesSeparatelyAsync()
{
// Arrange
var agent1 = new TestAIAgent();
var agent2 = new TestAIAgent();
var inner = new CountingSkillsSource(
[
new AgentInlineSkill("skill-a", "A", "Instructions A."),
]);
var source = new CachingAgentSkillsSource(
inner,
new CachingAgentSkillsSourceOptions
{
CacheIsolationKeySelector = context => ReferenceEquals(context.Agent, agent1) ? "agent-1" : "agent-2",
});
var context1 = TestAgentSkillsSourceContextFactory.Create(agent1);
var context2 = TestAgentSkillsSourceContextFactory.Create(agent2);
// Act
await source.GetSkillsAsync(context1, CancellationToken.None);
await source.GetSkillsAsync(context2, CancellationToken.None);
// Assert
Assert.Equal(2, inner.CallCount);
}
[Fact]
public async Task GetSkillsAsync_NullIsolationKey_UsesSharedCacheAsync()
{
// Arrange
var inner = new CountingSkillsSource(
[
new AgentInlineSkill("skill-a", "A", "Instructions A."),
]);
var source = new CachingAgentSkillsSource(
inner,
new CachingAgentSkillsSourceOptions { CacheIsolationKeySelector = _ => null });
var context1 = TestAgentSkillsSourceContextFactory.Create();
var context2 = TestAgentSkillsSourceContextFactory.Create();
// Act
var result1 = await source.GetSkillsAsync(context1, CancellationToken.None);
var result2 = await source.GetSkillsAsync(context2, CancellationToken.None);
// Assert
Assert.Same(result1, result2);
Assert.Equal(1, inner.CallCount);
}
[Fact]
public async Task GetSkillsAsync_EmptyStringIsolationKey_IsolatedFromSharedCacheAsync()
{
// Arrange
string? isolationKey = null;
var inner = new CountingSkillsSource(
[
new AgentInlineSkill("skill-a", "A", "Instructions A."),
]);
var source = new CachingAgentSkillsSource(
inner,
new CachingAgentSkillsSourceOptions { CacheIsolationKeySelector = _ => isolationKey });
var context = TestAgentSkillsSourceContextFactory.Create();
// Act
await source.GetSkillsAsync(context, CancellationToken.None); // shared bucket
isolationKey = string.Empty;
await source.GetSkillsAsync(context, CancellationToken.None); // distinct empty-string bucket
// Assert
Assert.Equal(2, inner.CallCount);
}
[Fact]
public async Task GetSkillsAsync_FirstCallerCancels_OtherCallerStillGetsResultAsync()
{
// Arrange
var gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var inner = new DelayedSkillsSource(gate.Task,
[
new AgentInlineSkill("skill-a", "A", "Instructions A."),
]);
var source = new CachingAgentSkillsSource(inner);
var context = TestAgentSkillsSourceContextFactory.Create();
using var cts1 = new CancellationTokenSource();
using var cts2 = new CancellationTokenSource();
// Act — start the first caller and wait until it owns the gate and its fetch has started,
// so the second caller is guaranteed to queue behind it (and not become the fetch owner).
var task1 = source.GetSkillsAsync(context, cts1.Token);
await inner.Started;
// The second caller now queues behind the first on the cache gate.
var task2 = source.GetSkillsAsync(context, cts2.Token);
// Cancel the first caller; its fetch is cancelled and the gate is released.
cts1.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() => task1);
// The second caller acquires the gate and starts a fresh fetch; release the inner source.
gate.SetResult(true);
var result = await task2;
// Assert — the second caller performs its own fetch (restart semantics).
Assert.Single(result);
Assert.Equal("skill-a", result[0].Frontmatter.Name);
Assert.Equal(2, inner.CallCount);
Assert.False(inner.LastCancellationToken.IsCancellationRequested);
}
[Fact]
public async Task GetSkillsAsync_AllCallersCancel_InnerSourceIsCancelledAsync()
{
// Arrange
var gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var inner = new DelayedSkillsSource(gate.Task,
[
new AgentInlineSkill("skill-a", "A", "Instructions A."),
]);
var source = new CachingAgentSkillsSource(inner);
var context = TestAgentSkillsSourceContextFactory.Create();
using var cts1 = new CancellationTokenSource();
using var cts2 = new CancellationTokenSource();
// Act
var task1 = source.GetSkillsAsync(context, cts1.Token);
var task2 = source.GetSkillsAsync(context, cts2.Token);
// Wait until the first caller's fetch has started; the second caller is queued on the gate.
await inner.Started;
// Cancel both callers
cts1.Cancel();
cts2.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() => task1);
await Assert.ThrowsAsync<OperationCanceledException>(() => task2);
// Assert — the inner source's cancellation token should have been triggered
Assert.True(inner.LastCancellationToken.IsCancellationRequested);
}
[Fact]
public async Task GetSkillsAsync_SingleCallerCancels_InnerSourceIsCancelledAsync()
{
// Arrange
var gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var inner = new DelayedSkillsSource(gate.Task,
[
new AgentInlineSkill("skill-a", "A", "Instructions A."),
]);
var source = new CachingAgentSkillsSource(inner);
var context = TestAgentSkillsSourceContextFactory.Create();
using var cts = new CancellationTokenSource();
// Act
var task = source.GetSkillsAsync(context, cts.Token);
// Give task time to start
await inner.Started;
// Cancel the only caller
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() => task);
// Assert — the inner source's cancellation token should have been triggered
Assert.True(inner.LastCancellationToken.IsCancellationRequested);
}
[Fact]
public async Task GetSkillsAsync_CancelledCaller_DoesNotPoisonSubsequentCallAsync()
{
// Arrange
var gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var inner = new DelayedSkillsSource(gate.Task,
[
new AgentInlineSkill("skill-a", "A", "Instructions A."),
]);
var source = new CachingAgentSkillsSource(inner);
var context = TestAgentSkillsSourceContextFactory.Create();
using var cts = new CancellationTokenSource();
// Act — first caller cancels while alone, cancelling the inner fetch.
var task = source.GetSkillsAsync(context, cts.Token);
await inner.Started;
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() => task);
// A fresh caller should trigger a new fetch and succeed.
gate.SetResult(true);
var result = await source.GetSkillsAsync(context, CancellationToken.None);
// Assert
Assert.Single(result);
Assert.Equal(2, inner.CallCount);
}
[Fact]
public async Task GetSkillsAsync_WithinRefreshInterval_ReturnsCachedResultAsync()
{
// Arrange
var inner = new CountingSkillsSource(
[
new AgentInlineSkill("skill-a", "A", "Instructions A."),
]);
var source = new CachingAgentSkillsSource(
inner,
new CachingAgentSkillsSourceOptions { RefreshInterval = TimeSpan.FromHours(1) });
var context = TestAgentSkillsSourceContextFactory.Create();
// Act — both calls fall well within the refresh interval.
var result1 = await source.GetSkillsAsync(context, CancellationToken.None);
var result2 = await source.GetSkillsAsync(context, CancellationToken.None);
// Assert — the cached result is reused while fresh.
Assert.Same(result1, result2);
Assert.Equal(1, inner.CallCount);
}
[Fact]
public async Task GetSkillsAsync_ZeroRefreshInterval_AlwaysRefetchesAsync()
{
// Arrange
var inner = new CountingSkillsSource(
[
new AgentInlineSkill("skill-a", "A", "Instructions A."),
]);
var source = new CachingAgentSkillsSource(
inner,
new CachingAgentSkillsSourceOptions { RefreshInterval = TimeSpan.Zero });
var context = TestAgentSkillsSourceContextFactory.Create();
// Act
await source.GetSkillsAsync(context, CancellationToken.None);
await source.GetSkillsAsync(context, CancellationToken.None);
// Assert — a zero interval treats the cached result as always stale.
Assert.Equal(2, inner.CallCount);
}
[Fact]
public async Task GetSkillsAsync_NegativeRefreshInterval_AlwaysRefetchesAsync()
{
// Arrange
var inner = new CountingSkillsSource(
[
new AgentInlineSkill("skill-a", "A", "Instructions A."),
]);
var source = new CachingAgentSkillsSource(
inner,
new CachingAgentSkillsSourceOptions { RefreshInterval = TimeSpan.FromMinutes(-1) });
var context = TestAgentSkillsSourceContextFactory.Create();
// Act
await source.GetSkillsAsync(context, CancellationToken.None);
await source.GetSkillsAsync(context, CancellationToken.None);
// Assert — a negative interval treats the cached result as always stale.
Assert.Equal(2, inner.CallCount);
}
[Fact]
public async Task GetSkillsAsync_NullRefreshInterval_ReturnsCachedResultAsync()
{
// Arrange
var inner = new CountingSkillsSource(
[
new AgentInlineSkill("skill-a", "A", "Instructions A."),
]);
var source = new CachingAgentSkillsSource(
inner,
new CachingAgentSkillsSourceOptions { RefreshInterval = null });
var context = TestAgentSkillsSourceContextFactory.Create();
// Act
var result1 = await source.GetSkillsAsync(context, CancellationToken.None);
var result2 = await source.GetSkillsAsync(context, CancellationToken.None);
// Assert — with no interval the cached result never expires.
Assert.Same(result1, result2);
Assert.Equal(1, inner.CallCount);
}
[Fact]
public void Dispose_DisposesInnerSource()
{
// Arrange
var inner = new DisposeTrackingSkillsSource();
var source = new CachingAgentSkillsSource(inner);
// Act
source.Dispose();
// Assert — disposal cascades to the wrapped source.
Assert.Equal(1, inner.DisposeCount);
}
[Fact]
public async Task Dispose_IsIdempotentAsync()
{
// Arrange
var inner = new DisposeTrackingSkillsSource();
var source = new CachingAgentSkillsSource(inner);
var context = TestAgentSkillsSourceContextFactory.Create();
// Populate a cache entry so a semaphore exists to dispose.
await source.GetSkillsAsync(context, CancellationToken.None);
// Act
source.Dispose();
source.Dispose();
// Assert — the inner source is disposed exactly once.
Assert.Equal(1, inner.DisposeCount);
}
[Fact]
public async Task GetSkillsAsync_AfterDispose_ThrowsObjectDisposedExceptionAsync()
{
// Arrange
var inner = new DisposeTrackingSkillsSource();
var source = new CachingAgentSkillsSource(inner);
var context = TestAgentSkillsSourceContextFactory.Create();
source.Dispose();
// Act & Assert — calls after disposal fail deterministically and create no cache entries.
await Assert.ThrowsAsync<ObjectDisposedException>(() => source.GetSkillsAsync(context, CancellationToken.None));
}
private sealed class DisposeTrackingSkillsSource : AgentSkillsSource
{
public int DisposeCount { get; private set; }
public override Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
=> Task.FromResult<IList<AgentSkill>>([]);
protected override void Dispose(bool disposing)
{
if (disposing)
{
this.DisposeCount++;
}
base.Dispose(disposing);
}
}
private sealed class CountingSkillsSource : AgentSkillsSource
{
private readonly IList<AgentSkill> _skills;
private int _callCount;
public CountingSkillsSource(IList<AgentSkill> skills)
{
this._skills = skills;
}
public int CallCount => this._callCount;
public override Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref this._callCount);
return Task.FromResult(this._skills);
}
}
private sealed class FailingSkillsSource : AgentSkillsSource
{
private readonly bool _failOnFirstCall;
private int _callCount;
public FailingSkillsSource(bool failOnFirstCall)
{
this._failOnFirstCall = failOnFirstCall;
}
public int CallCount => this._callCount;
public override Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
{
var count = Interlocked.Increment(ref this._callCount);
if (this._failOnFirstCall && count == 1)
{
throw new InvalidOperationException("Simulated failure.");
}
return Task.FromResult<IList<AgentSkill>>(new List<AgentSkill>
{
new AgentInlineSkill("recovered-skill", "Recovered", "Body."),
});
}
}
private sealed class DelayedSkillsSource : AgentSkillsSource
{
private readonly Task _gate;
private readonly IList<AgentSkill> _skills;
private readonly TaskCompletionSource<bool> _started = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _callCount;
public DelayedSkillsSource(Task gate, IList<AgentSkill> skills)
{
this._gate = gate;
this._skills = skills;
}
public int CallCount => this._callCount;
public CancellationToken LastCancellationToken { get; private set; }
/// <summary>Completes once the inner source has started executing at least once.</summary>
public Task Started => this._started.Task;
public override async Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref this._callCount);
this.LastCancellationToken = cancellationToken;
this._started.TrySetResult(true);
// Wait for the gate to open or for cancellation, whichever comes first.
var cancellationTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using (cancellationToken.Register(static state => ((TaskCompletionSource<bool>)state!).TrySetResult(true), cancellationTcs))
{
if (this._gate == await Task.WhenAny(this._gate, cancellationTcs.Task).ConfigureAwait(false))
{
return this._skills;
}
}
cancellationToken.ThrowIfCancellationRequested();
return this._skills; // Unreachable
}
}
}
@@ -0,0 +1,101 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="DeduplicatingAgentSkillsSource"/>.
/// </summary>
public sealed class DeduplicatingAgentSkillsSourceTests
{
[Fact]
public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync()
{
// Arrange
var inner = new AgentInMemorySkillsSource(new AgentSkill[]
{
new AgentInlineSkill("skill-a", "A", "Instructions A."),
new AgentInlineSkill("skill-b", "B", "Instructions B."),
});
var source = new DeduplicatingAgentSkillsSource(inner);
// Act
var result = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert
Assert.Equal(2, result.Count);
}
[Fact]
public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync()
{
// Arrange
var skills = new AgentSkill[]
{
new AgentInlineSkill("dupe", "First", "Instructions 1."),
new AgentInlineSkill("dupe", "Second", "Instructions 2."),
new AgentInlineSkill("unique", "Unique", "Instructions 3."),
};
var inner = new AgentInMemorySkillsSource(skills);
var source = new DeduplicatingAgentSkillsSource(inner);
// Act
var result = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("First", result.First(s => s.Frontmatter.Name == "dupe").Frontmatter.Description);
Assert.Contains(result, s => s.Frontmatter.Name == "unique");
}
[Fact]
public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync()
{
// Arrange - Use a custom source that returns skills with same name but different casing
var inner = new FakeDuplicateCaseSource();
var source = new DeduplicatingAgentSkillsSource(inner);
// Act
var result = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert
Assert.Single(result);
Assert.Equal("First", result[0].Frontmatter.Description);
}
[Fact]
public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync()
{
// Arrange
var inner = new AgentInMemorySkillsSource(System.Array.Empty<AgentSkill>());
var source = new DeduplicatingAgentSkillsSource(inner);
// Act
var result = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert
Assert.Empty(result);
}
/// <summary>
/// A fake source that returns skills with names differing only by case.
/// </summary>
private sealed class FakeDuplicateCaseSource : AgentSkillsSource
{
public override Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
{
// AgentSkillFrontmatter validates names must be lowercase, so we build
// two skills with the same lowercase name to test case-insensitive dedup.
var skills = new List<AgentSkill>
{
new AgentInlineSkill("my-skill", "First", "Instructions 1."),
new AgentInlineSkill("my-skill", "Second", "Instructions 2."),
};
return Task.FromResult<IList<AgentSkill>>(skills);
}
}
}
@@ -0,0 +1,157 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="FilteringAgentSkillsSource"/>.
/// </summary>
public sealed class FilteringAgentSkillsSourceTests
{
[Fact]
public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync()
{
// Arrange
var inner = new AgentInMemorySkillsSource(new AgentSkill[]
{
new AgentInlineSkill("skill-a", "A", "Instructions A."),
new AgentInlineSkill("skill-b", "B", "Instructions B."),
});
var source = new FilteringAgentSkillsSource(inner, (_, _) => true);
// Act
var result = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert
Assert.Equal(2, result.Count);
}
[Fact]
public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync()
{
// Arrange
var inner = new AgentInMemorySkillsSource(new AgentSkill[]
{
new AgentInlineSkill("skill-a", "A", "Instructions A."),
new AgentInlineSkill("skill-b", "B", "Instructions B."),
});
var source = new FilteringAgentSkillsSource(inner, (_, _) => false);
// Act
var result = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert
Assert.Empty(result);
}
[Fact]
public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync()
{
// Arrange
var inner = new AgentInMemorySkillsSource(new AgentSkill[]
{
new AgentInlineSkill("keep-me", "Keep", "Instructions."),
new AgentInlineSkill("drop-me", "Drop", "Instructions."),
new AgentInlineSkill("keep-also", "KeepAlso", "Instructions."),
});
var source = new FilteringAgentSkillsSource(
inner,
(skill, context) => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase));
// Act
var result = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert
Assert.Equal(2, result.Count);
Assert.All(result, s => Assert.StartsWith("keep", s.Frontmatter.Name));
}
[Fact]
public async Task GetSkillsAsync_PassesContextToPredicateAsync()
{
// Arrange
var inner = new AgentInMemorySkillsSource(new AgentSkill[]
{
new AgentInlineSkill("skill-a", "A", "Instructions A."),
});
var expectedContext = TestAgentSkillsSourceContextFactory.Create();
AgentSkillsSourceContext? actualSkillsSourceContext = null;
AgentSkill? actualSkill = null;
var source = new FilteringAgentSkillsSource(
inner,
(skill, context) =>
{
actualSkill = skill;
actualSkillsSourceContext = context;
return true;
});
// Act
var result = await source.GetSkillsAsync(expectedContext, CancellationToken.None);
// Assert
var skill = Assert.Single(result);
Assert.Same(skill, actualSkill);
Assert.Same(expectedContext, actualSkillsSourceContext);
}
[Fact]
public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync()
{
// Arrange
var inner = new AgentInMemorySkillsSource(Array.Empty<AgentSkill>());
var source = new FilteringAgentSkillsSource(inner, (_, _) => true);
// Act
var result = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert
Assert.Empty(result);
}
[Fact]
public void Constructor_NullPredicate_Throws()
{
// Arrange
var inner = new AgentInMemorySkillsSource(Array.Empty<AgentSkill>());
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new FilteringAgentSkillsSource(inner, null!));
}
[Fact]
public void Constructor_NullInnerSource_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new FilteringAgentSkillsSource(null!, (_, _) => true));
}
[Fact]
public async Task GetSkillsAsync_PreservesOrderAsync()
{
// Arrange
var inner = new AgentInMemorySkillsSource(new AgentSkill[]
{
new AgentInlineSkill("alpha", "Alpha", "Instructions."),
new AgentInlineSkill("beta", "Beta", "Instructions."),
new AgentInlineSkill("gamma", "Gamma", "Instructions."),
new AgentInlineSkill("delta", "Delta", "Instructions."),
});
// Keep only alpha and gamma
var source = new FilteringAgentSkillsSource(
inner,
(skill, context) => skill.Frontmatter.Name is "alpha" or "gamma");
// Act
var result = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("alpha", result[0].Frontmatter.Name);
Assert.Equal("gamma", result[1].Frontmatter.Name);
}
}
@@ -0,0 +1,321 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests that verify the Hosted-AgentSkills sample patterns: ZIP extraction with
/// zip-slip guard, skill name validation, and AgentSkillsProvider loading from
/// downloaded skill directories (the Foundry download → extract → wire-into-provider flow).
/// </summary>
public sealed class HostedAgentSkillsPatternTests : IDisposable
{
private readonly string _testRoot;
private readonly TestAIAgent _agent = new();
public HostedAgentSkillsPatternTests()
{
this._testRoot = Path.Combine(Path.GetTempPath(), "hosted-skills-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(this._testRoot);
}
public void Dispose()
{
if (Directory.Exists(this._testRoot))
{
Directory.Delete(this._testRoot, recursive: true);
}
}
// ── ZIP extraction tests ──────────────────────────────────────────────────
[Fact]
public void SafeExtractZip_ValidArchive_ExtractsToDestination()
{
// Arrange
string destDir = Path.Combine(this._testRoot, "valid-extract");
Directory.CreateDirectory(destDir);
byte[] zip = CreateZipWithEntry("SKILL.md", "---\nname: test\ndescription: Test\n---\nBody.");
// Act
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
SafeExtractZip(archive, destDir);
// Assert
Assert.True(File.Exists(Path.Combine(destDir, "SKILL.md")));
string content = File.ReadAllText(Path.Combine(destDir, "SKILL.md"));
Assert.Contains("name: test", content);
}
[Fact]
public void SafeExtractZip_ZipSlipAttempt_ThrowsInvalidOperationException()
{
// Arrange
string destDir = Path.Combine(this._testRoot, "zipslip-test");
Directory.CreateDirectory(destDir);
byte[] zip = CreateZipWithEntry("../../../evil.txt", "malicious content");
// Act & Assert
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
var ex = Assert.Throws<InvalidOperationException>(() => SafeExtractZip(archive, destDir));
Assert.Contains("outside of", ex.Message);
}
[Fact]
public void SafeExtractZip_SiblingPrefixAttack_ThrowsInvalidOperationException()
{
// Arrange — sibling path that starts with the dest dir name
string destDir = Path.Combine(this._testRoot, "target");
Directory.CreateDirectory(destDir);
byte[] zip = CreateZipWithEntry("../target-evil/payload.txt", "exploit");
// Act & Assert
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
var ex = Assert.Throws<InvalidOperationException>(() => SafeExtractZip(archive, destDir));
Assert.Contains("outside of", ex.Message);
}
[Fact]
public void SafeExtractZip_DirectoryEntry_CreatesDirectory()
{
// Arrange
string destDir = Path.Combine(this._testRoot, "dir-entry");
Directory.CreateDirectory(destDir);
byte[] zip = CreateZipWithDirectoryEntry("subdir/");
// Act
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
SafeExtractZip(archive, destDir);
// Assert
Assert.True(Directory.Exists(Path.Combine(destDir, "subdir")));
}
[Fact]
public void SafeExtractZip_NestedFileWithinDestination_Extracts()
{
// Arrange — a legitimate nested entry must still pass the single containment gate
string destDir = Path.Combine(this._testRoot, "nested-extract");
Directory.CreateDirectory(destDir);
byte[] zip = CreateZipWithEntry("docs/SKILL.md", "nested body");
// Act
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
SafeExtractZip(archive, destDir);
// Assert
string extracted = Path.Combine(destDir, "docs", "SKILL.md");
Assert.True(File.Exists(extracted));
Assert.Equal("nested body", File.ReadAllText(extracted));
}
// ── Skill name validation tests ──────────────────────────────────────────
[Theory]
[InlineData("../escape")]
[InlineData("path/traversal")]
[InlineData("path\\traversal")]
[InlineData("has.dots")]
public void ValidateSkillName_InvalidNames_Rejected(string name)
{
// Act & Assert
Assert.True(IsInvalidSkillName(name), $"Expected '{name}' to be rejected.");
}
[Theory]
[InlineData("support-style")]
[InlineData("escalation-policy")]
[InlineData("my-skill-123")]
public void ValidateSkillName_ValidNames_Accepted(string name)
{
// Act & Assert
Assert.False(IsInvalidSkillName(name), $"Expected '{name}' to be accepted.");
}
// ── AgentSkillsProvider integration with downloaded skill directories ─────
[Fact]
public async Task AgentSkillsProvider_WithDownloadedSkills_AdvertisesAndLoadsAsync()
{
// Arrange — simulate the Foundry download + extract flow
string downloadDir = Path.Combine(this._testRoot, "downloaded_skills");
Directory.CreateDirectory(downloadDir);
CreateDownloadedSkill(downloadDir, "support-style",
"---\nname: support-style\ndescription: Contoso Outdoors customer-support tone and formatting guidelines.\n---\n\n# Contoso Outdoors Support Style\n\nYou are speaking on behalf of Contoso Outdoors.\n\n## Canary\n\nInclude STYLE-CANARY-3318.");
CreateDownloadedSkill(downloadDir, "escalation-policy",
"---\nname: escalation-policy\ndescription: When and how to escalate Contoso Outdoors customer-support tickets.\n---\n\n# Escalation Policy\n\nProvide ESC-CANARY-7742.");
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
var inputContext = new AIContext
{
Instructions = "You are a customer-support assistant for Contoso Outdoors."
};
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert — skills are advertised in instructions
Assert.NotNull(result.Instructions);
Assert.Contains("support-style", result.Instructions);
Assert.Contains("escalation-policy", result.Instructions);
Assert.Contains("Contoso Outdoors customer-support tone", result.Instructions);
// Assert — load_skill tool is available
Assert.NotNull(result.Tools);
var toolNames = result.Tools!.Select(t => t.Name).ToList();
Assert.Contains("load_skill", toolNames);
// All tools are always included regardless of whether skills have resources or scripts
Assert.Contains("read_skill_resource", toolNames);
Assert.Contains("run_skill_script", toolNames);
}
[Fact]
public async Task LoadSkill_ReturnsFullContentWithCanaryAsync()
{
// Arrange
string downloadDir = Path.Combine(this._testRoot, "canary_skills");
Directory.CreateDirectory(downloadDir);
CreateDownloadedSkill(downloadDir, "support-style",
"---\nname: support-style\ndescription: Contoso tone guidelines.\n---\n\nInclude STYLE-CANARY-3318 at the bottom.");
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
var inputContext = new AIContext();
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
Assert.NotNull(loadSkillTool);
// Act
var content = await loadSkillTool!.InvokeAsync(
new AIFunctionArguments(new System.Collections.Generic.Dictionary<string, object?> { ["skillName"] = "support-style" }));
// Assert
var text = content!.ToString()!;
Assert.Contains("STYLE-CANARY-3318", text);
Assert.Contains("name: support-style", text);
}
[Fact]
public async Task LoadSkill_UnknownName_ReturnsErrorAsync()
{
// Arrange
string downloadDir = Path.Combine(this._testRoot, "error_skills");
Directory.CreateDirectory(downloadDir);
CreateDownloadedSkill(downloadDir, "support-style",
"---\nname: support-style\ndescription: Test\n---\nBody.");
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
var inputContext = new AIContext();
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
// Act
var content = await loadSkillTool!.InvokeAsync(
new AIFunctionArguments(new System.Collections.Generic.Dictionary<string, object?> { ["skillName"] = "nonexistent-skill" }));
// Assert
var text = content!.ToString()!;
Assert.Contains("Error", text);
Assert.Contains("not found", text);
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>
/// Creates a downloaded skill directory with a SKILL.md file — simulating what
/// the Foundry download + ZIP extract flow produces.
/// </summary>
private static void CreateDownloadedSkill(string parentDir, string name, string content)
{
string skillDir = Path.Combine(parentDir, name);
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), content);
}
/// <summary>
/// Creates a ZIP archive in memory containing a single file entry.
/// </summary>
private static byte[] CreateZipWithEntry(string entryName, string content)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(entryName);
using var writer = new StreamWriter(entry.Open());
writer.Write(content);
}
return ms.ToArray();
}
/// <summary>
/// Creates a ZIP archive in memory containing a single directory entry.
/// </summary>
private static byte[] CreateZipWithDirectoryEntry(string directoryName)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
// Directory entries in ZIPs have an empty name portion and end with /
archive.CreateEntry(directoryName);
}
return ms.ToArray();
}
/// <summary>
/// Mirrors the zip-slip guard from the Hosted-AgentSkills sample Program.cs.
/// </summary>
private static void SafeExtractZip(ZipArchive archive, string destinationDir)
{
string destRoot = Path.GetFullPath(destinationDir);
string destRootWithSep = Path.EndsInDirectorySeparator(destRoot)
? destRoot
: destRoot + Path.DirectorySeparatorChar;
var comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
foreach (ZipArchiveEntry entry in archive.Entries)
{
// Resolve the entry against the destination, then require the result to stay within the
// destination subtree. A single StartsWith containment check is the only gate to
// extraction, so any entry that escapes (for example via '..') is rejected.
string entryPath = Path.GetFullPath(Path.Combine(destRoot, entry.FullName));
if (!entryPath.StartsWith(destRootWithSep, comparison))
{
throw new InvalidOperationException(
$"Refusing to extract unsafe path '{entry.FullName}' outside of '{destRoot}'.");
}
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(entryPath);
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(entryPath)!);
entry.ExtractToFile(entryPath, overwrite: true);
}
}
}
/// <summary>
/// Mirrors the skill name validation from the Hosted-AgentSkills sample Program.cs.
/// </summary>
private static bool IsInvalidSkillName(string name) =>
name.Contains('.') || name.Contains('/') || name.Contains('\\') || Path.IsPathRooted(name);
}
@@ -0,0 +1,344 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for argument marshaling integration via <c>Func&lt;JsonElement?, AIFunctionArguments&gt;</c>.
/// </summary>
public sealed class SkillScriptArgumentMarshalerTests
{
/// <summary>
/// Creates a JsonElement with ValueKind.String containing the given string value.
/// This simulates how vLLM backends send arguments as a string-wrapped JSON object.
/// </summary>
private static JsonElement CreateStringElement(string value)
{
// JSON encoding of a string value: surround with quotes and escape inner quotes
string json = "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
using var doc = JsonDocument.Parse(json);
return doc.RootElement.Clone();
}
[Fact]
public async Task DefaultMarshaler_NullArguments_ReturnsEmptyAsync()
{
// Arrange
var script = new AgentInlineSkillScript("test", () => "ok");
var skill = new AgentInlineSkill("s", "d", "i");
// Act
var result = await script.RunAsync(skill, null, null, CancellationToken.None);
// Assert
Assert.Equal("ok", result?.ToString());
}
[Fact]
public async Task DefaultMarshaler_JsonNull_ReturnsEmptyAsync()
{
// Arrange
var script = new AgentInlineSkillScript("test", () => "ok");
var skill = new AgentInlineSkill("s", "d", "i");
using var doc = JsonDocument.Parse("null");
var element = doc.RootElement.Clone();
// Act
var result = await script.RunAsync(skill, element, null, CancellationToken.None);
// Assert
Assert.Equal("ok", result?.ToString());
}
[Fact]
public async Task DefaultMarshaler_UndefinedArguments_ReturnsEmptyAsync()
{
// Arrange
var script = new AgentInlineSkillScript("test", () => "ok");
var skill = new AgentInlineSkill("s", "d", "i");
JsonElement? element = default(JsonElement); // ValueKind == Undefined
// Act
var result = await script.RunAsync(skill, element, null, CancellationToken.None);
// Assert
Assert.Equal("ok", result?.ToString());
}
[Fact]
public async Task DefaultMarshaler_ObjectArguments_PassesPropertiesAsync()
{
// Arrange
var script = new AgentInlineSkillScript("test", (string query, int maxResults) => $"{query}:{maxResults}");
var skill = new AgentInlineSkill("s", "d", "i");
using var doc = JsonDocument.Parse("""{"query":"hello","maxResults":5}""");
var element = doc.RootElement.Clone();
// Act
var result = await script.RunAsync(skill, element, null, CancellationToken.None);
// Assert
Assert.Equal("hello:5", result?.ToString());
}
[Fact]
public async Task DefaultMarshaler_StringArguments_ThrowsAsync()
{
// Arrange
var script = new AgentInlineSkillScript("test", (string query) => query);
var skill = new AgentInlineSkill("s", "d", "i");
var element = CreateStringElement("{\"query\": \"hello\"}");
// Act & Assert
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => script.RunAsync(skill, element, null, CancellationToken.None));
Assert.Contains("String", ex.Message);
}
[Fact]
public async Task DefaultMarshaler_NumberArguments_ThrowsAsync()
{
// Arrange
var script = new AgentInlineSkillScript("test", (string query) => query);
var skill = new AgentInlineSkill("s", "d", "i");
using var doc = JsonDocument.Parse("42");
var element = doc.RootElement.Clone();
// Act & Assert
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => script.RunAsync(skill, element, null, CancellationToken.None));
Assert.Contains("Number", ex.Message);
}
[Fact]
public async Task InlineSkillScript_UsesCustomMarshaler_WhenProvidedAsync()
{
// Arrange
var script = new AgentInlineSkillScript(
"test-script",
(string query) => $"result: {query}",
argumentMarshaler: StringParsingMarshaler);
var skill = new AgentInlineSkill("test-skill", "desc", "instructions");
// Create string-wrapped JSON arguments (simulating vLLM behavior)
var stringArgs = CreateStringElement("{\"query\":\"hello\"}");
// Act
var result = await script.RunAsync(skill, stringArgs, null, CancellationToken.None);
// Assert
Assert.Equal("result: hello", result?.ToString());
}
[Fact]
public async Task InlineSkill_SkillLevelMarshaler_InheritedByScriptsAsync()
{
// Arrange
var skill = new AgentInlineSkill("test-skill", "desc", "instructions", argumentMarshaler: StringParsingMarshaler)
.AddScript("search", (string query) => $"found: {query}");
var script = await skill.GetScriptAsync("search", CancellationToken.None);
Assert.NotNull(script);
// Create string-wrapped JSON arguments
var stringArgs = CreateStringElement("{\"query\":\"world\"}");
// Act
var result = await script.RunAsync(skill, stringArgs, null, CancellationToken.None);
// Assert
Assert.Equal("found: world", result?.ToString());
}
[Fact]
public async Task InlineSkillScript_ScriptLevelMarshaler_OverridesSkillLevelAsync()
{
// Arrange — skill has a throwing marshaler, but script has its own
Func<JsonElement?, AIFunctionArguments> throwingMarshaler = ThrowingMarshaler;
var skill = new AgentInlineSkill("test-skill", "desc", "instructions", argumentMarshaler: throwingMarshaler);
// Create a script directly with a per-script marshaler
var script = new AgentInlineSkillScript("search", (string query) => $"found: {query}", argumentMarshaler: StringParsingMarshaler);
// Create string-wrapped JSON arguments
var stringArgs = CreateStringElement("{\"query\":\"test\"}");
// Act — should use scriptMarshaler (not the throwing skill marshaler)
var result = await script.RunAsync(skill, stringArgs, null, CancellationToken.None);
// Assert
Assert.Equal("found: test", result?.ToString());
}
[Fact]
public async Task ClassSkill_ArgumentMarshaler_UsedByDiscoveredScriptsAsync()
{
// Arrange
var skill = new TestClassSkillWithMarshaler();
var script = await skill.GetScriptAsync("greet", CancellationToken.None);
Assert.NotNull(script);
// Create string-wrapped JSON arguments
var stringArgs = CreateStringElement("{\"name\":\"Alice\"}");
// Act
var result = await script.RunAsync(skill, stringArgs, null, CancellationToken.None);
// Assert
Assert.Equal("Hello, Alice!", result?.ToString());
}
[Fact]
public async Task ClassSkill_CreateScript_UsesArgumentMarshalerAsync()
{
// Arrange — a class skill that defines its scripts programmatically via CreateScript
var skill = new TestClassSkillWithCreateScript();
var script = await skill.GetScriptAsync("echo", CancellationToken.None);
Assert.NotNull(script);
// Create string-wrapped JSON arguments (simulating vLLM behavior)
var stringArgs = CreateStringElement("{\"value\":\"hi\"}");
// Act
var result = await script.RunAsync(skill, stringArgs, null, CancellationToken.None);
// Assert
Assert.Equal("echo: hi", result?.ToString());
}
[Fact]
public async Task ClassSkill_NoMarshaler_UsesDefaultObjectMarshalingAsync()
{
// Arrange — a class skill without a custom marshaler falls back to default behavior
var skill = new TestClassSkillWithoutMarshaler();
var script = await skill.GetScriptAsync("greet", CancellationToken.None);
Assert.NotNull(script);
using var doc = JsonDocument.Parse("""{"name":"Bob"}""");
var objectArgs = doc.RootElement.Clone();
// Act — plain JSON object is marshaled by the default marshaler
var result = await script.RunAsync(skill, objectArgs, null, CancellationToken.None);
// Assert
Assert.Equal("Hello, Bob!", result?.ToString());
}
[Fact]
public async Task ClassSkill_NoMarshaler_StringArguments_ThrowsAsync()
{
// Arrange — default marshaler rejects string-wrapped arguments
var skill = new TestClassSkillWithoutMarshaler();
var script = await skill.GetScriptAsync("greet", CancellationToken.None);
Assert.NotNull(script);
var stringArgs = CreateStringElement("{\"name\":\"Bob\"}");
// Act & Assert
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => script.RunAsync(skill, stringArgs, null, CancellationToken.None));
Assert.Contains("String", ex.Message);
}
/// <summary>
/// A custom marshaler function that handles string-wrapped JSON (simulating the vLLM fix).
/// </summary>
private static AIFunctionArguments StringParsingMarshaler(JsonElement? arguments)
{
if (arguments is null ||
arguments.Value.ValueKind == JsonValueKind.Null ||
arguments.Value.ValueKind == JsonValueKind.Undefined)
{
return [];
}
JsonElement element = arguments.Value;
if (element.ValueKind == JsonValueKind.String)
{
string? raw = element.GetString();
if (raw is not null)
{
using var innerDoc = JsonDocument.Parse(raw);
element = innerDoc.RootElement.Clone();
}
}
if (element.ValueKind != JsonValueKind.Object)
{
throw new InvalidOperationException($"Cannot marshal arguments of kind '{element.ValueKind}'.");
}
var dict = new Dictionary<string, object?>();
foreach (var property in element.EnumerateObject())
{
dict[property.Name] = property.Value;
}
return new AIFunctionArguments(dict);
}
/// <summary>
/// A marshaler function that always throws — used to verify override behavior.
/// </summary>
private static AIFunctionArguments ThrowingMarshaler(JsonElement? arguments)
{
throw new InvalidOperationException("ThrowingMarshaler should not be called.");
}
/// <summary>
/// A class-based skill with a custom argument marshaler.
/// </summary>
private sealed class TestClassSkillWithMarshaler : AgentClassSkill<TestClassSkillWithMarshaler>
{
public TestClassSkillWithMarshaler()
: base(argumentMarshaler: StringParsingMarshaler)
{
}
public override AgentSkillFrontmatter Frontmatter { get; } = new("test-class-skill", "A test class skill.");
protected override string Instructions => "Test instructions.";
[AgentSkillScript("greet")]
public static string Greet(string name) => $"Hello, {name}!";
}
/// <summary>
/// A class-based skill that defines its scripts programmatically via <c>CreateScript</c>,
/// passing the constructor-supplied argument marshaler through to each script.
/// </summary>
private sealed class TestClassSkillWithCreateScript : AgentClassSkill<TestClassSkillWithCreateScript>
{
public TestClassSkillWithCreateScript()
: base(argumentMarshaler: StringParsingMarshaler)
{
}
public override AgentSkillFrontmatter Frontmatter { get; } = new("create-script-skill", "A test class skill using CreateScript.");
protected override string Instructions => "Test instructions.";
public override IReadOnlyList<AgentSkillScript>? Scripts =>
[this.CreateScript("echo", (string value) => $"echo: {value}")];
}
/// <summary>
/// A class-based skill without a custom argument marshaler (uses default object marshaling).
/// </summary>
private sealed class TestClassSkillWithoutMarshaler : AgentClassSkill<TestClassSkillWithoutMarshaler>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("default-marshaler-skill", "A test class skill without a marshaler.");
protected override string Instructions => "Test instructions.";
[AgentSkillScript("greet")]
public static string Greet(string name) => $"Hello, {name}!";
}
}
@@ -0,0 +1,116 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// A simple in-memory <see cref="AgentSkill"/> implementation for unit tests.
/// </summary>
internal sealed class TestAgentSkill : AgentSkill
{
private readonly AgentSkillFrontmatter _frontmatter;
private readonly string _content;
/// <summary>
/// Initializes a new instance of the <see cref="TestAgentSkill"/> class.
/// </summary>
/// <param name="name">Kebab-case skill name.</param>
/// <param name="description">Skill description.</param>
/// <param name="content">Full skill content (body text).</param>
public TestAgentSkill(string name, string description, string content)
{
this._frontmatter = new AgentSkillFrontmatter(name, description);
this._content = content;
}
/// <inheritdoc/>
public override AgentSkillFrontmatter Frontmatter => this._frontmatter;
/// <inheritdoc/>
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default) => new(this._content);
}
/// <summary>
/// A simple in-memory <see cref="AgentSkillsSource"/> implementation for unit tests.
/// </summary>
internal sealed class TestAgentSkillsSource : AgentSkillsSource
{
private readonly IList<AgentSkill> _skills;
/// <summary>
/// Initializes a new instance of the <see cref="TestAgentSkillsSource"/> class.
/// </summary>
/// <param name="skills">The skills to return.</param>
public TestAgentSkillsSource(IList<AgentSkill> skills)
{
this._skills = skills;
}
/// <summary>
/// Initializes a new instance of the <see cref="TestAgentSkillsSource"/> class.
/// </summary>
/// <param name="skills">The skills to return.</param>
public TestAgentSkillsSource(params AgentSkill[] skills)
{
this._skills = skills;
}
/// <inheritdoc/>
public override Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
{
return Task.FromResult(this._skills);
}
}
/// <summary>
/// Custom input type accepted by skill script delegates in JSO tests.
/// </summary>
internal sealed class LookupRequest
{
/// <summary>Gets or sets the search query.</summary>
public string Query { get; set; } = string.Empty;
/// <summary>Gets or sets the maximum number of results.</summary>
public int MaxResults { get; set; }
}
/// <summary>
/// Custom output type returned by skill script delegates in JSO tests.
/// </summary>
internal sealed class LookupResponse
{
/// <summary>Gets or sets the items found.</summary>
public IList<string> Items { get; set; } = [];
/// <summary>Gets or sets the total number of matches.</summary>
public int TotalCount { get; set; }
}
/// <summary>
/// Custom output type returned by skill resource delegates in JSO tests.
/// </summary>
internal sealed class SkillConfig
{
/// <summary>Gets or sets the theme name.</summary>
public string Theme { get; set; } = string.Empty;
/// <summary>Gets or sets whether verbose mode is enabled.</summary>
public bool Verbose { get; set; }
}
/// <summary>
/// Source-generated JSON serializer context for skill test types.
/// Provides serialization support for <see cref="LookupRequest"/>, <see cref="LookupResponse"/>,
/// and <see cref="SkillConfig"/> without requiring runtime reflection.
/// </summary>
[JsonSourceGenerationOptions]
[JsonSerializable(typeof(LookupRequest))]
[JsonSerializable(typeof(LookupResponse))]
[JsonSerializable(typeof(SkillConfig))]
internal sealed partial class SkillTestJsonContext : JsonSerializerContext
{
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,666 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
public class ApprovalNotRequiredFunctionBypassingChatClientTests
{
#region GetResponseAsync Tests
[Fact]
public async Task GetResponseAsync_NoApprovalContent_PassesThroughUnchangedAsync()
{
// Arrange
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Hello")])));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
// Act
var response = await RunWithAgentContextAsync(decorator, session);
// Assert
Assert.Single(response.Messages);
Assert.Equal("Hello", response.Messages[0].Text);
Assert.Equal(0, session.StateBag.Count);
}
[Fact]
public async Task GetResponseAsync_AllToolsRequireApproval_PassesThroughUnchangedAsync()
{
// Arrange
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
var fcc = new FunctionCallContent("call1", "approvalTool");
var approval = new ToolApprovalRequestContent("req1", fcc);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, [approval])])));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [approvalTool] };
// Act
var response = await RunWithAgentContextAsync(decorator, session, options);
// Assert — approval request should remain
Assert.Single(response.Messages);
var contents = response.Messages[0].Contents;
Assert.Single(contents);
Assert.IsType<ToolApprovalRequestContent>(contents[0]);
Assert.Equal(0, session.StateBag.Count);
}
[Fact]
public async Task GetResponseAsync_MixedApproval_RemovesApprovalNotRequiredItemsAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
var fccNormal = new FunctionCallContent("call1", "normalTool");
var fccApproval = new FunctionCallContent("call2", "approvalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([
new ChatMessage(ChatRole.Assistant, [approvalNormal, approvalRequired])
])));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
// Act
var response = await RunWithAgentContextAsync(decorator, session, options);
// Assert — only the approval-required item remains in the response
Assert.Single(response.Messages);
var contents = response.Messages[0].Contents;
Assert.Single(contents);
var remainingApproval = Assert.IsType<ToolApprovalRequestContent>(contents[0]);
Assert.Equal("req2", remainingApproval.RequestId);
}
[Fact]
public async Task GetResponseAsync_MixedApproval_StoresAutoApprovedInSessionAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
var fccNormal = new FunctionCallContent("call1", "normalTool");
var fccApproval = new FunctionCallContent("call2", "approvalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([
new ChatMessage(ChatRole.Assistant, [approvalNormal, approvalRequired])
])));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
// Act
await RunWithAgentContextAsync(decorator, session, options);
// Assert — the auto-approved item should be stored in the session
Assert.True(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey, out var stored, AgentJsonUtilities.DefaultOptions));
Assert.NotNull(stored);
Assert.Single(stored!);
Assert.Equal("req1", stored![0].RequestId);
}
[Fact]
public async Task GetResponseAsync_AllApprovalNotRequired_RemovesAllApprovalsAndRemovesEmptyMessageAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var fccNormal = new FunctionCallContent("call1", "normalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([
new ChatMessage(ChatRole.Assistant, [approvalNormal])
])));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool] };
// Act
var response = await RunWithAgentContextAsync(decorator, session, options);
// Assert — the message should be removed since it's now empty
Assert.Empty(response.Messages);
}
[Fact]
public async Task GetResponseAsync_PreExistingEmptyMessage_IsPreservedAsync()
{
// Arrange — the response contains a metadata-only message that was already empty
// (no content items) before the decorator runs, alongside a normal text message.
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([
new ChatMessage(ChatRole.Assistant, contents: []),
new ChatMessage(ChatRole.Assistant, "Hello")
])));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
// Act
var response = await RunWithAgentContextAsync(decorator, session);
// Assert — the decorator must not drop a message it did not empty itself.
Assert.Equal(2, response.Messages.Count);
Assert.Empty(response.Messages[0].Contents);
Assert.Equal("Hello", response.Messages[1].Text);
}
[Fact]
public async Task GetResponseAsync_NextRequest_InjectsStoredAutoApprovalsAsync()
{
// Arrange
var fccNormal = new FunctionCallContent("call1", "normalTool");
var storedApproval = new ToolApprovalRequestContent("req1", fccNormal);
var session = new ChatClientAgentSession();
session.StateBag.SetValue(
ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey,
new List<ToolApprovalRequestContent> { storedApproval },
AgentJsonUtilities.DefaultOptions);
IEnumerable<ChatMessage>? capturedMessages = null;
var innerClient = CreateMockChatClient((messages, _, _) =>
{
capturedMessages = messages.ToList();
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")]));
});
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
var options = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "result", "normalTool")] };
// Act
await RunWithAgentContextAsync(decorator, session, options);
// Assert — the inner client should receive injected messages
Assert.NotNull(capturedMessages);
var messagesList = capturedMessages!.ToList();
// Original user message + user message with approved responses.
Assert.Equal(2, messagesList.Count);
Assert.Equal(ChatRole.User, messagesList[0].Role);
// User message with the auto-approved ToolApprovalResponseContent
Assert.Equal(ChatRole.User, messagesList[1].Role);
var userContent = messagesList[1].Contents.OfType<ToolApprovalResponseContent>().ToList();
Assert.Single(userContent);
Assert.Equal("req1", userContent[0].RequestId);
Assert.True(userContent[0].Approved);
}
[Fact]
public async Task GetResponseAsync_NextRequest_ClearsStoredAfterInjectionAsync()
{
// Arrange
var fccNormal = new FunctionCallContent("call1", "normalTool");
var storedApproval = new ToolApprovalRequestContent("req1", fccNormal);
var session = new ChatClientAgentSession();
session.StateBag.SetValue(
ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey,
new List<ToolApprovalRequestContent> { storedApproval },
AgentJsonUtilities.DefaultOptions);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")])));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
var options = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "result", "normalTool")] };
// Act
await RunWithAgentContextAsync(decorator, session, options);
// Assert — the stored data should be cleared after successful injection
Assert.False(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey, out _, AgentJsonUtilities.DefaultOptions));
}
[Fact]
public async Task GetResponseAsync_UnknownTool_TreatedAsApprovalRequiredAsync()
{
// Arrange — tool is not in ChatOptions.Tools
var fccUnknown = new FunctionCallContent("call1", "unknownTool");
var approvalUnknown = new ToolApprovalRequestContent("req1", fccUnknown);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([
new ChatMessage(ChatRole.Assistant, [approvalUnknown])
])));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [] };
// Act
var response = await RunWithAgentContextAsync(decorator, session, options);
// Assert — unknown tool should NOT be auto-approved
Assert.Single(response.Messages);
Assert.Single(response.Messages[0].Contents);
Assert.IsType<ToolApprovalRequestContent>(response.Messages[0].Contents[0]);
Assert.Equal(0, session.StateBag.Count);
}
[Fact]
public async Task GetResponseAsync_StoredRequestToolSetChanged_StillInjectsAsApprovedAsync()
{
// Arrange — tool was previously non-approval-required but is now wrapped in ApprovalRequiredAIFunction.
// The LLM still requires a complete set of responses, so we inject unconditionally.
var fccTool = new FunctionCallContent("call1", "changingTool");
var storedApproval = new ToolApprovalRequestContent("req1", fccTool);
var session = new ChatClientAgentSession();
session.StateBag.SetValue(
ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey,
new List<ToolApprovalRequestContent> { storedApproval },
AgentJsonUtilities.DefaultOptions);
IEnumerable<ChatMessage>? capturedMessages = null;
var innerClient = CreateMockChatClient((messages, _, _) =>
{
capturedMessages = messages.ToList();
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")]));
});
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
// The tool is now wrapped in ApprovalRequiredAIFunction — but we still inject unconditionally
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "changingTool"));
var options = new ChatOptions { Tools = [approvalTool] };
// Act
await RunWithAgentContextAsync(decorator, session, options);
// Assert — the stored request should still be injected as approved
Assert.NotNull(capturedMessages);
var messagesList = capturedMessages!.ToList();
Assert.Equal(2, messagesList.Count);
var userContent = messagesList[1].Contents.OfType<ToolApprovalResponseContent>().ToList();
Assert.Single(userContent);
Assert.Equal("req1", userContent[0].RequestId);
Assert.True(userContent[0].Approved);
// Session should be cleared
Assert.False(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey, out _, AgentJsonUtilities.DefaultOptions));
}
#endregion
#region GetStreamingResponseAsync Tests
[Fact]
public async Task GetStreamingResponseAsync_NoApprovalContent_PassesThroughUnchangedAsync()
{
// Arrange
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
ToAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "Hello")));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
// Act
var updates = new List<ChatResponseUpdate>();
await RunStreamingWithAgentContextAsync(decorator, session, updates);
// Assert
Assert.Single(updates);
Assert.Equal("Hello", updates[0].Text);
Assert.Equal(0, session.StateBag.Count);
}
[Fact]
public async Task GetStreamingResponseAsync_MixedApproval_FiltersApprovalNotRequiredItemsAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
var fccNormal = new FunctionCallContent("call1", "normalTool");
var fccApproval = new FunctionCallContent("call2", "approvalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
ToAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "text"),
new ChatResponseUpdate { Contents = [approvalNormal, approvalRequired] }));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
// Act
var updates = new List<ChatResponseUpdate>();
await RunStreamingWithAgentContextAsync(decorator, session, updates, options);
// Assert — text update + filtered approval update
Assert.Equal(2, updates.Count);
Assert.Equal("text", updates[0].Text);
// Second update should only have the approval-required item
var approvalContents = updates[1].Contents.OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(approvalContents);
Assert.Equal("req2", approvalContents[0].RequestId);
}
[Fact]
public async Task GetStreamingResponseAsync_MixedApproval_StoresAutoApprovedInSessionAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
var fccNormal = new FunctionCallContent("call1", "normalTool");
var fccApproval = new FunctionCallContent("call2", "approvalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
ToAsyncEnumerableAsync(
new ChatResponseUpdate { Contents = [approvalNormal, approvalRequired] }));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
// Act
var updates = new List<ChatResponseUpdate>();
await RunStreamingWithAgentContextAsync(decorator, session, updates, options);
// Assert — the auto-approved item should be stored in the session
Assert.True(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey, out var stored, AgentJsonUtilities.DefaultOptions));
Assert.NotNull(stored);
Assert.Single(stored!);
Assert.Equal("req1", stored![0].RequestId);
}
[Fact]
public async Task GetStreamingResponseAsync_AllApprovalNotRequired_SkipsEmptyUpdateAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var fccNormal = new FunctionCallContent("call1", "normalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
ToAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "text"),
new ChatResponseUpdate { Contents = [approvalNormal] }));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool] };
// Act
var updates = new List<ChatResponseUpdate>();
await RunStreamingWithAgentContextAsync(decorator, session, updates, options);
// Assert — the approval update should be skipped entirely
Assert.Single(updates);
Assert.Equal("text", updates[0].Text);
}
#endregion
#region No-Context Pass-Through Tests
[Fact]
public async Task GetResponseAsync_NoRunContext_PassesThroughWithoutBypassingAsync()
{
// Arrange
var fcc = new FunctionCallContent("call1", "normalTool");
var approval = new ToolApprovalRequestContent("req1", fcc);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, [approval])])));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
// Act — calling directly without agent context; the decorator should no-op and pass through.
var response = await decorator.GetResponseAsync([new ChatMessage(ChatRole.User, "test")]);
// Assert — the approval request is surfaced to the caller unchanged (not bypassed).
var contents = response.Messages.Single().Contents;
Assert.IsType<ToolApprovalRequestContent>(Assert.Single(contents));
}
[Fact]
public async Task GetResponseAsync_NoSession_PassesThroughWithoutBypassingAsync()
{
// Arrange
var fcc = new FunctionCallContent("call1", "normalTool");
var approval = new ToolApprovalRequestContent("req1", fcc);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, [approval])])));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
// Act — run with an agent context but a null session; the decorator should no-op and pass through.
var response = await RunWithAgentContextAsync(decorator, session: null!);
// Assert — the approval request is surfaced to the caller unchanged (not bypassed).
var contents = response.Messages.Single().Contents;
Assert.IsType<ToolApprovalRequestContent>(Assert.Single(contents));
}
[Fact]
public async Task GetStreamingResponseAsync_NoRunContext_PassesThroughWithoutBypassingAsync()
{
// Arrange
var fcc = new FunctionCallContent("call1", "normalTool");
var approval = new ToolApprovalRequestContent("req1", fcc);
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
ToAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, [approval])));
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
// Act — calling directly without agent context; the decorator should no-op and pass through.
var updates = new List<ChatResponseUpdate>();
await foreach (var update in decorator.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "test")]))
{
updates.Add(update);
}
// Assert — the approval request is surfaced to the caller unchanged (not bypassed).
Assert.Contains(updates.SelectMany(u => u.Contents), c => c is ToolApprovalRequestContent);
}
#endregion
#region Builder Extension Tests
[Fact]
public void UseApprovalNotRequiredFunctionBypassing_AddsDecoratorToPipeline()
{
// Arrange
var innerClient = new Mock<IChatClient>().Object;
// Act
var pipeline = innerClient.AsBuilder()
.UseApprovalNotRequiredFunctionBypassing()
.Build();
// Assert
Assert.NotNull(pipeline.GetService<ApprovalNotRequiredFunctionBypassingChatClient>());
}
[Fact]
public async Task UseApprovalNotRequiredFunctionBypassing_ExplicitLoggerFactory_IsUsedForWarningAsync()
{
// Arrange
var loggerMock = new Mock<ILogger>();
loggerMock.Setup(l => l.IsEnabled(LogLevel.Warning)).Returns(true);
var loggerFactoryMock = new Mock<ILoggerFactory>();
loggerFactoryMock.Setup(f => f.CreateLogger(It.IsAny<string>())).Returns(loggerMock.Object);
var fcc = new FunctionCallContent("call1", "normalTool");
var approval = new ToolApprovalRequestContent("req1", fcc);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, [approval])])));
var pipeline = innerClient.AsBuilder()
.UseApprovalNotRequiredFunctionBypassing(loggerFactoryMock.Object)
.Build();
// Act — invoked without an agent run context, so the decorator no-ops and logs a warning
// via the explicitly provided logger factory.
var response = await pipeline.GetResponseAsync([new ChatMessage(ChatRole.User, "test")]);
// Assert — the provided factory was used to emit a warning, and the approval request is surfaced.
loggerFactoryMock.Verify(f => f.CreateLogger(It.IsAny<string>()), Times.Once);
loggerMock.Verify(
l => l.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception?>(),
(Func<It.IsAnyType, Exception?, string>)It.IsAny<object>()),
Times.Once);
Assert.IsType<ToolApprovalRequestContent>(Assert.Single(response.Messages.Single().Contents));
}
[Fact]
public void WithDefaultAgentMiddleware_ByDefault_InjectsDecorator()
{
// Arrange
var innerClient = new Mock<IChatClient>().Object;
var options = new ChatClientAgentOptions();
// Act
var pipeline = innerClient.WithDefaultAgentMiddleware(options);
// Assert
Assert.NotNull(pipeline.GetService<ApprovalNotRequiredFunctionBypassingChatClient>());
}
[Fact]
public void WithDefaultAgentMiddleware_DisableApprovalNotRequiredFunctionBypassing_DoesNotInjectDecorator()
{
// Arrange
var innerClient = new Mock<IChatClient>().Object;
var options = new ChatClientAgentOptions { DisableApprovalNotRequiredFunctionBypassing = true };
// Act
var pipeline = innerClient.WithDefaultAgentMiddleware(options);
// Assert
Assert.Null(pipeline.GetService<ApprovalNotRequiredFunctionBypassingChatClient>());
}
#endregion
#region Helpers
private static async Task<ChatResponse> RunWithAgentContextAsync(
ApprovalNotRequiredFunctionBypassingChatClient decorator,
AgentSession? session,
ChatOptions? options = null)
{
ChatResponse? capturedResponse = null;
var agent = new TestAIAgent
{
RunAsyncFunc = async (messages, agentSession, agentOptions, ct) =>
{
capturedResponse = await decorator.GetResponseAsync(messages, options, ct);
return new AgentResponse(capturedResponse);
}
};
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], session);
return capturedResponse!;
}
private static Task<ChatResponse> RunWithAgentContextAsync(
ApprovalNotRequiredFunctionBypassingChatClient decorator,
AgentSession session)
=> RunWithAgentContextAsync(decorator, session, options: null);
private static async Task RunStreamingWithAgentContextAsync(
ApprovalNotRequiredFunctionBypassingChatClient decorator,
AgentSession session,
List<ChatResponseUpdate> updates,
ChatOptions? options = null)
{
var agent = new TestAIAgent
{
RunAsyncFunc = async (messages, agentSession, agentOptions, ct) =>
{
await foreach (var update in decorator.GetStreamingResponseAsync(messages, options, ct))
{
updates.Add(update);
}
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]);
}
};
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], session);
}
private static IChatClient CreateMockChatClient(
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, Task<ChatResponse>> onGetResponse)
{
var mock = new Mock<IChatClient>();
mock.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions?>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> m, ChatOptions? o, CancellationToken ct) => onGetResponse(m, o, ct));
return mock.Object;
}
private static IChatClient CreateMockStreamingChatClient(
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, IAsyncEnumerable<ChatResponseUpdate>> onGetStreamingResponse)
{
var mock = new Mock<IChatClient>();
mock.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions?>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> m, ChatOptions? o, CancellationToken ct) => onGetStreamingResponse(m, o, ct));
return mock.Object;
}
private static async IAsyncEnumerable<ChatResponseUpdate> ToAsyncEnumerableAsync(params ChatResponseUpdate[] updates)
{
foreach (var update in updates)
{
yield return update;
}
await Task.CompletedTask;
}
#endregion
}
@@ -0,0 +1,128 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests;
public class ChatClientAgentContinuationTokenTests
{
[Fact]
public void ToBytes_Roundtrip()
{
// Arrange
ResponseContinuationToken originalToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 });
ChatClientAgentContinuationToken chatClientToken = new(originalToken)
{
InputMessages =
[
new ChatMessage(ChatRole.User, "Hello!"),
new ChatMessage(ChatRole.User, "How are you?")
],
ResponseUpdates =
[
new ChatResponseUpdate(ChatRole.Assistant, "I'm fine, thank you."),
new ChatResponseUpdate(ChatRole.Assistant, "How can I assist you today?")
]
};
// Act
ReadOnlyMemory<byte> bytes = chatClientToken.ToBytes();
ChatClientAgentContinuationToken tokenFromBytes = ChatClientAgentContinuationToken.FromToken(ResponseContinuationToken.FromBytes(bytes));
// Assert
Assert.NotNull(tokenFromBytes);
Assert.Equal(chatClientToken.ToBytes().ToArray(), tokenFromBytes.ToBytes().ToArray());
// Verify InnerToken
Assert.Equal(chatClientToken.InnerToken.ToBytes().ToArray(), tokenFromBytes.InnerToken.ToBytes().ToArray());
// Verify InputMessages
Assert.NotNull(tokenFromBytes.InputMessages);
Assert.Equal(chatClientToken.InputMessages.Count(), tokenFromBytes.InputMessages.Count());
for (int i = 0; i < chatClientToken.InputMessages.Count(); i++)
{
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Role, tokenFromBytes.InputMessages.ElementAt(i).Role);
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Text, tokenFromBytes.InputMessages.ElementAt(i).Text);
}
// Verify ResponseUpdates
Assert.NotNull(tokenFromBytes.ResponseUpdates);
Assert.Equal(chatClientToken.ResponseUpdates.Count, tokenFromBytes.ResponseUpdates.Count);
for (int i = 0; i < chatClientToken.ResponseUpdates.Count; i++)
{
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Role, tokenFromBytes.ResponseUpdates.ElementAt(i).Role);
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Text, tokenFromBytes.ResponseUpdates.ElementAt(i).Text);
}
}
[Fact]
public void Serialization_Roundtrip()
{
// Arrange
ResponseContinuationToken originalToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 });
ChatClientAgentContinuationToken chatClientToken = new(originalToken)
{
InputMessages =
[
new ChatMessage(ChatRole.User, "Hello!"),
new ChatMessage(ChatRole.User, "How are you?")
],
ResponseUpdates =
[
new ChatResponseUpdate(ChatRole.Assistant, "I'm fine, thank you."),
new ChatResponseUpdate(ChatRole.Assistant, "How can I assist you today?")
]
};
// Act
string json = JsonSerializer.Serialize(chatClientToken, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
ResponseContinuationToken? deserializedToken = (ResponseContinuationToken?)JsonSerializer.Deserialize(json, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
ChatClientAgentContinuationToken deserializedChatClientToken = ChatClientAgentContinuationToken.FromToken(deserializedToken!);
// Assert
Assert.NotNull(deserializedChatClientToken);
Assert.Equal(chatClientToken.ToBytes().ToArray(), deserializedChatClientToken.ToBytes().ToArray());
// Verify InnerToken
Assert.Equal(chatClientToken.InnerToken.ToBytes().ToArray(), deserializedChatClientToken.InnerToken.ToBytes().ToArray());
// Verify InputMessages
Assert.NotNull(deserializedChatClientToken.InputMessages);
Assert.Equal(chatClientToken.InputMessages.Count(), deserializedChatClientToken.InputMessages.Count());
for (int i = 0; i < chatClientToken.InputMessages.Count(); i++)
{
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Role, deserializedChatClientToken.InputMessages.ElementAt(i).Role);
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Text, deserializedChatClientToken.InputMessages.ElementAt(i).Text);
}
// Verify ResponseUpdates
Assert.NotNull(deserializedChatClientToken.ResponseUpdates);
Assert.Equal(chatClientToken.ResponseUpdates.Count, deserializedChatClientToken.ResponseUpdates.Count);
for (int i = 0; i < chatClientToken.ResponseUpdates.Count; i++)
{
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Role, deserializedChatClientToken.ResponseUpdates.ElementAt(i).Role);
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Text, deserializedChatClientToken.ResponseUpdates.ElementAt(i).Text);
}
}
[Fact]
public void FromToken_WithChatClientAgentContinuationToken_ReturnsSameInstance()
{
// Arrange
ChatClientAgentContinuationToken originalToken = new(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 }));
// Act
ChatClientAgentContinuationToken fromToken = ChatClientAgentContinuationToken.FromToken(originalToken);
// Assert
Assert.Same(originalToken, fromToken);
}
}
@@ -0,0 +1,200 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="ChatClientAgentOptions"/> class.
/// </summary>
public class ChatClientAgentOptionsTests
{
[Fact]
public void DefaultConstructor_InitializesWithNullValues()
{
// Act
var options = new ChatClientAgentOptions();
// Assert
Assert.Null(options.Name);
Assert.Null(options.Description);
Assert.Null(options.ChatOptions);
Assert.Null(options.ChatHistoryProvider);
Assert.Null(options.AIContextProviders);
Assert.False(options.UseProvidedChatClientAsIs);
Assert.True(options.ClearOnChatHistoryProviderConflict);
Assert.True(options.WarnOnChatHistoryProviderConflict);
Assert.True(options.ThrowOnChatHistoryProviderConflict);
}
[Fact]
public void Constructor_WithNullValues_SetsPropertiesCorrectly()
{
// Act
var options = new ChatClientAgentOptions() { Name = null, Description = null, ChatOptions = new() { Tools = null, Instructions = null } };
// Assert
Assert.Null(options.Name);
Assert.Null(options.Description);
Assert.Null(options.AIContextProviders);
Assert.Null(options.ChatHistoryProvider);
Assert.NotNull(options.ChatOptions);
Assert.Null(options.ChatOptions.Instructions);
Assert.Null(options.ChatOptions.Tools);
}
[Fact]
public void Constructor_WithToolsOnly_SetsChatOptionsWithTools()
{
// Arrange
var tools = new List<AITool> { AIFunctionFactory.Create(() => "test") };
// Act
var options = new ChatClientAgentOptions()
{
Name = null,
Description = null,
ChatOptions = new() { Tools = tools }
};
// Assert
Assert.Null(options.Name);
Assert.Null(options.Description);
Assert.NotNull(options.ChatOptions);
AssertSameTools(tools, options.ChatOptions.Tools);
}
[Fact]
public void Constructor_WithAllParameters_SetsAllPropertiesCorrectly()
{
// Arrange
const string Instructions = "Test instructions";
const string Name = "Test name";
const string Description = "Test description";
var tools = new List<AITool> { AIFunctionFactory.Create(() => "test") };
// Act
var options = new ChatClientAgentOptions()
{
Name = Name,
Description = Description,
ChatOptions = new() { Tools = tools, Instructions = Instructions }
};
// Assert
Assert.Equal(Name, options.Name);
Assert.Equal(Instructions, options.ChatOptions.Instructions);
Assert.Equal(Description, options.Description);
Assert.NotNull(options.ChatOptions);
AssertSameTools(tools, options.ChatOptions.Tools);
}
[Fact]
public void Constructor_WithNameAndDescriptionOnly_DoesNotCreateChatOptions()
{
// Arrange
const string Name = "Test name";
const string Description = "Test description";
// Act
var options = new ChatClientAgentOptions()
{
Name = Name,
Description = Description,
};
// Assert
Assert.Equal(Name, options.Name);
Assert.Equal(Description, options.Description);
Assert.Null(options.ChatOptions);
}
[Fact]
public void Clone_CreatesDeepCopyWithSameValues()
{
// Arrange
const string Name = "Test name";
const string Description = "Test description";
var tools = new List<AITool> { AIFunctionFactory.Create(() => "test") };
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null).Object;
var mockAIContextProvider = new Mock<AIContextProvider>(null, null, null).Object;
var original = new ChatClientAgentOptions()
{
Name = Name,
Description = Description,
ChatOptions = new() { Tools = tools },
Id = "test-id",
ChatHistoryProvider = mockChatHistoryProvider,
AIContextProviders = [mockAIContextProvider],
UseProvidedChatClientAsIs = true,
ClearOnChatHistoryProviderConflict = false,
WarnOnChatHistoryProviderConflict = false,
ThrowOnChatHistoryProviderConflict = false,
DisableApprovalNotRequiredFunctionBypassing = true,
};
// Act
var clone = original.Clone();
// Assert
Assert.NotSame(original, clone);
Assert.Equal(original.Id, clone.Id);
Assert.Equal(original.Name, clone.Name);
Assert.Equal(original.Description, clone.Description);
Assert.Same(original.ChatHistoryProvider, clone.ChatHistoryProvider);
Assert.Equal(original.AIContextProviders, clone.AIContextProviders);
Assert.Equal(original.UseProvidedChatClientAsIs, clone.UseProvidedChatClientAsIs);
Assert.Equal(original.ClearOnChatHistoryProviderConflict, clone.ClearOnChatHistoryProviderConflict);
Assert.Equal(original.WarnOnChatHistoryProviderConflict, clone.WarnOnChatHistoryProviderConflict);
Assert.Equal(original.ThrowOnChatHistoryProviderConflict, clone.ThrowOnChatHistoryProviderConflict);
Assert.Equal(original.DisableApprovalNotRequiredFunctionBypassing, clone.DisableApprovalNotRequiredFunctionBypassing);
// ChatOptions should be cloned, not the same reference
Assert.NotSame(original.ChatOptions, clone.ChatOptions);
Assert.Equal(original.ChatOptions?.Instructions, clone.ChatOptions?.Instructions);
Assert.Equal(original.ChatOptions?.Tools, clone.ChatOptions?.Tools);
}
[Fact]
public void Clone_WithoutProvidingChatOptions_ClonesCorrectly()
{
// Arrange
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null).Object;
var mockAIContextProvider = new Mock<AIContextProvider>(null, null, null).Object;
var original = new ChatClientAgentOptions
{
Id = "test-id",
Name = "Test name",
Description = "Test description",
ChatHistoryProvider = mockChatHistoryProvider,
AIContextProviders = [mockAIContextProvider]
};
// Act
var clone = original.Clone();
// Assert
Assert.NotSame(original, clone);
Assert.Equal(original.Id, clone.Id);
Assert.Equal(original.Name, clone.Name);
Assert.Equal(original.Description, clone.Description);
Assert.Null(original.ChatOptions);
Assert.Same(original.ChatHistoryProvider, clone.ChatHistoryProvider);
Assert.Equal(original.AIContextProviders, clone.AIContextProviders);
}
private static void AssertSameTools(IList<AITool>? expected, IList<AITool>? actual)
{
var index = 0;
foreach (var tool in expected ?? [])
{
Assert.Same(tool, actual?[index]);
index++;
}
}
}
@@ -0,0 +1,422 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
public class ChatClientAgentRunOptionsTests
{
/// <summary>
/// Verify that ChatClientAgentRunOptions constructor works with null chatOptions.
/// </summary>
[Fact]
public void ConstructorWorksWithNullChatOptions()
{
// Act
var runOptions = new ChatClientAgentRunOptions();
// Assert
Assert.Null(runOptions.ChatOptions);
}
/// <summary>
/// Verify that ChatClientAgentRunOptions ChatOptions property is set and mutable.
/// </summary>
[Fact]
public void ChatOptionsPropertyIsReadOnly()
{
// Arrange
var chatOptions = new ChatOptions { MaxOutputTokens = 100 };
var runOptions = new ChatClientAgentRunOptions(chatOptions);
chatOptions.MaxOutputTokens = 200; // Change the property to verify mutability
// Act & Assert
Assert.Same(chatOptions, runOptions.ChatOptions);
// Verify that the property doesn't have a setter by checking if it's the same instance
var retrievedOptions = runOptions.ChatOptions!;
Assert.Same(chatOptions, retrievedOptions);
Assert.Equal(200, retrievedOptions.MaxOutputTokens); // Ensure the change is reflected
}
#region ChatClientFactory Tests
/// <summary>
/// Tests that ChatClientFactory is called and transforms the client for RunAsync.
/// </summary>
[Fact]
public async Task RunAsync_WithChatClientFactory_UsesTransformedClientAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
var transformedClient = new Mock<IChatClient>();
var factoryCallCount = 0;
// Setup the original client to throw if called (should not be used)
originalClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Throws(new InvalidOperationException("Original client should not be called"));
// Setup the transformed client to return a response
transformedClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Transformed response")]));
// Create the factory that transforms the client
IChatClient ClientFactory(IChatClient client)
{
factoryCallCount++;
Assert.Same(originalClient.Object, client); // Verify original client is passed
return transformedClient.Object;
}
var agent = new ChatClientAgent(originalClient.Object, new ChatClientAgentOptions() { UseProvidedChatClientAsIs = true });
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
// Act
var response = await agent.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.NotNull(response);
Assert.Equal(1, factoryCallCount); // Factory should be called exactly once
transformedClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
originalClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Never);
}
/// <summary>
/// Tests that ChatClientFactory is called and transforms the client for RunStreamingAsync.
/// </summary>
[Fact]
public async Task RunStreamingAsync_WithChatClientFactory_UsesTransformedClientAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
var transformedClient = new Mock<IChatClient>();
var factoryCallCount = 0;
// Setup the original client to throw if called (should not be used)
originalClient.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Throws(new InvalidOperationException("Original client should not be called"));
// Setup the transformed client to return streaming responses
var streamingResponses = new[]
{
new ChatResponseUpdate { Contents = [new TextContent("Streaming ")] },
new ChatResponseUpdate { Contents = [new TextContent("response")] }
};
transformedClient.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(streamingResponses.ToAsyncEnumerable());
// Create the factory that transforms the client
IChatClient ClientFactory(IChatClient client)
{
factoryCallCount++;
Assert.Same(originalClient.Object, client); // Verify original client is passed
return transformedClient.Object;
}
var agent = new ChatClientAgent(originalClient.Object, new ChatClientAgentOptions() { UseProvidedChatClientAsIs = true });
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
// Act
var responseUpdates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(messages, null, options, CancellationToken.None))
{
responseUpdates.Add(update);
}
// Assert
Assert.NotEmpty(responseUpdates);
Assert.Equal(1, factoryCallCount); // Factory should be called exactly once
transformedClient.Verify(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
originalClient.Verify(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Never);
}
/// <summary>
/// Tests that without ChatClientFactory, the original client is used for RunAsync.
/// </summary>
[Fact]
public async Task RunAsync_WithoutChatClientFactory_UsesOriginalClientAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
originalClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Original response")]));
var agent = new ChatClientAgent(originalClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
// Act - No ChatClientFactory provided
var response = await agent.RunAsync(messages, null, null, CancellationToken.None);
// Assert
Assert.NotNull(response);
originalClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Tests that without ChatClientFactory, the original client is used for RunStreamingAsync.
/// </summary>
[Fact]
public async Task RunStreamingAsync_WithoutChatClientFactory_UsesOriginalClientAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
var streamingResponses = new[]
{
new ChatResponseUpdate { Contents = [new TextContent("Original ")] },
new ChatResponseUpdate { Contents = [new TextContent("streaming")] }
};
originalClient.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(streamingResponses.ToAsyncEnumerable());
var agent = new ChatClientAgent(originalClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
// Act - No ChatClientFactory provided
var responseUpdates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(messages, null, null, CancellationToken.None))
{
responseUpdates.Add(update);
}
// Assert
Assert.NotEmpty(responseUpdates);
originalClient.Verify(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Tests that ChatClientFactory is called for each separate RunAsync call.
/// </summary>
[Fact]
public async Task RunAsync_MultipleCalls_ChatClientFactoryCalledEachTimeAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
var transformedClient = new Mock<IChatClient>();
var factoryCallCount = 0;
transformedClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
IChatClient ClientFactory(IChatClient client)
{
factoryCallCount++;
return transformedClient.Object;
}
var agent = new ChatClientAgent(originalClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
// Act - Call RunAsync multiple times
await agent.RunAsync(messages, null, options, CancellationToken.None);
await agent.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.Equal(2, factoryCallCount); // Factory should be called for each run
transformedClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Exactly(2));
}
/// <summary>
/// Tests that subsequent calls without ChatClientFactory use the original client.
/// </summary>
[Fact]
public async Task RunAsync_AfterFactoryCall_WithoutFactory_UsesOriginalClientAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
var transformedClient = new Mock<IChatClient>();
originalClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Original response")]));
transformedClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Transformed response")]));
IChatClient ClientFactory(IChatClient client) => transformedClient.Object;
var agent = new ChatClientAgent(originalClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var optionsWithFactory = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
// Act - First call with factory, second call without
await agent.RunAsync(messages, null, optionsWithFactory, CancellationToken.None);
await agent.RunAsync(messages, null, null, CancellationToken.None);
// Assert
transformedClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
originalClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Tests that ChatClientFactory returning null throws an exception.
/// </summary>
[Fact]
public async Task RunAsync_ChatClientFactoryReturnsNull_ThrowsExceptionAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
static IChatClient ClientFactory(IChatClient client) => null!;
var agent = new ChatClientAgent(originalClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(async () =>
await agent.RunAsync(messages, null, options, CancellationToken.None));
}
#endregion
#region Clone Tests
/// <summary>
/// Verify that Clone returns a new instance with the same property values.
/// </summary>
[Fact]
public void CloneReturnsNewInstanceWithSameValues()
{
// Arrange
var chatOptions = new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f };
Func<IChatClient, IChatClient> factory = c => c;
var runOptions = new ChatClientAgentRunOptions(chatOptions)
{
ChatClientFactory = factory,
AllowBackgroundResponses = true,
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
AdditionalProperties = new AdditionalPropertiesDictionary
{
["key1"] = "value1"
}
};
// Act
AgentRunOptions cloneAsBase = runOptions.Clone();
// Assert
Assert.NotNull(cloneAsBase);
Assert.IsType<ChatClientAgentRunOptions>(cloneAsBase);
ChatClientAgentRunOptions clone = (ChatClientAgentRunOptions)cloneAsBase;
Assert.NotSame(runOptions, clone);
Assert.NotNull(clone.ChatOptions);
Assert.NotSame(runOptions.ChatOptions, clone.ChatOptions);
Assert.Equal(100, clone.ChatOptions!.MaxOutputTokens);
Assert.Equal(0.7f, clone.ChatOptions.Temperature);
Assert.Same(factory, clone.ChatClientFactory);
Assert.Equal(runOptions.AllowBackgroundResponses, clone.AllowBackgroundResponses);
Assert.Same(runOptions.ContinuationToken, clone.ContinuationToken);
Assert.NotNull(clone.AdditionalProperties);
Assert.NotSame(runOptions.AdditionalProperties, clone.AdditionalProperties);
Assert.Equal("value1", clone.AdditionalProperties["key1"]);
}
/// <summary>
/// Verify that modifying the cloned ChatOptions does not affect the original.
/// </summary>
[Fact]
public void CloneCreatesIndependentChatOptions()
{
// Arrange
var chatOptions = new ChatOptions { MaxOutputTokens = 100 };
var runOptions = new ChatClientAgentRunOptions(chatOptions);
// Act
ChatClientAgentRunOptions clone = (ChatClientAgentRunOptions)runOptions.Clone();
clone.ChatOptions!.MaxOutputTokens = 200;
// Assert
Assert.Equal(100, runOptions.ChatOptions!.MaxOutputTokens);
Assert.Equal(200, clone.ChatOptions.MaxOutputTokens);
}
/// <summary>
/// Verify that modifying the cloned AdditionalProperties does not affect the original.
/// </summary>
[Fact]
public void CloneCreatesIndependentAdditionalPropertiesDictionary()
{
// Arrange
var runOptions = new ChatClientAgentRunOptions
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
["key1"] = "value1"
}
};
// Act
ChatClientAgentRunOptions clone = (ChatClientAgentRunOptions)runOptions.Clone();
clone.AdditionalProperties!["key2"] = "value2";
// Assert
Assert.True(clone.AdditionalProperties.ContainsKey("key2"));
Assert.False(runOptions.AdditionalProperties.ContainsKey("key2"));
}
#endregion
}
@@ -0,0 +1,251 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.AI;
#pragma warning disable CA1861 // Avoid constant arrays as arguments
namespace Microsoft.Agents.AI.UnitTests;
public class ChatClientAgentSessionTests
{
#region Constructor and Property Tests
[Fact]
public void ConstructorSetsDefaults()
{
// Arrange & Act
var session = new ChatClientAgentSession();
// Assert
Assert.Null(session.ConversationId);
}
[Fact]
public void SetConversationIdRoundtrips()
{
// Arrange
var session = new ChatClientAgentSession();
const string ConversationId = "test-session-id";
// Act
session.ConversationId = ConversationId;
// Assert
Assert.Equal(ConversationId, session.ConversationId);
}
#endregion Constructor and Property Tests
#region Deserialize Tests
[Fact]
public void VerifyDeserializeWithMessages()
{
// Arrange
var json = JsonSerializer.Deserialize("""
{
"stateBag": {
"InMemoryChatHistoryProvider": {
"messages": [{"authorName": "testAuthor"}]
}
}
}
""", TestJsonSerializerContext.Default.JsonElement);
// Act.
var session = ChatClientAgentSession.Deserialize(json, TestJsonSerializerContext.Default.Options);
// Assert
Assert.Null(session.ConversationId);
var chatHistoryProvider = new InMemoryChatHistoryProvider();
var messages = chatHistoryProvider.GetMessages(session);
Assert.Single(messages);
Assert.Equal("testAuthor", messages[0].AuthorName);
}
[Fact]
public void VerifyDeserializeWithId()
{
// Arrange
var json = JsonSerializer.Deserialize("""
{
"conversationId": "TestConvId"
}
""", TestJsonSerializerContext.Default.JsonElement);
// Act
var session = ChatClientAgentSession.Deserialize(json);
// Assert
Assert.Equal("TestConvId", session.ConversationId);
}
[Fact]
public void VerifyDeserializeWithStateBag()
{
// Arrange
var json = JsonSerializer.Deserialize("""
{
"conversationId": "TestConvId",
"stateBag": {
"dog": {
"name": "Fido"
}
}
}
""", TestJsonSerializerContext.Default.JsonElement);
// Act
var session = ChatClientAgentSession.Deserialize(json);
// Assert
var dog = session.StateBag.GetValue<Animal>("dog", TestJsonSerializerContext.Default.Options);
Assert.NotNull(dog);
Assert.Equal("Fido", dog.Name);
}
[Fact]
public void DeserializeWithInvalidJsonThrows()
{
// Arrange
var invalidJson = JsonSerializer.Deserialize("[42]", TestJsonSerializerContext.Default.JsonElement);
// Act & Assert
Assert.Throws<ArgumentException>(() => ChatClientAgentSession.Deserialize(invalidJson));
}
#endregion Deserialize Tests
#region Serialize Tests
/// <summary>
/// Verify session serialization to JSON when the session has an id.
/// </summary>
[Fact]
public void VerifySessionSerializationWithId()
{
// Arrange
var session = new ChatClientAgentSession { ConversationId = "TestConvId" };
// Act
var json = session.Serialize();
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.True(json.TryGetProperty("conversationId", out var idProperty));
Assert.Equal("TestConvId", idProperty.GetString());
Assert.False(json.TryGetProperty("chatHistoryProviderState", out _));
}
/// <summary>
/// Verify session serialization to JSON when the session has messages.
/// </summary>
[Fact]
public void VerifySessionSerializationWithMessages()
{
// Arrange
var provider = new InMemoryChatHistoryProvider();
var session = new ChatClientAgentSession();
provider.SetMessages(session, [new(ChatRole.User, "TestContent") { AuthorName = "TestAuthor" }]);
// Act
var json = session.Serialize();
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.False(json.TryGetProperty("conversationId", out _));
// Messages should be stored in the stateBag
Assert.True(json.TryGetProperty("stateBag", out var stateBagProperty));
Assert.Equal(JsonValueKind.Object, stateBagProperty.ValueKind);
Assert.True(stateBagProperty.TryGetProperty("InMemoryChatHistoryProvider", out var providerStateProperty));
Assert.Equal(JsonValueKind.Object, providerStateProperty.ValueKind);
Assert.True(providerStateProperty.TryGetProperty("messages", out var messagesProperty));
Assert.Equal(JsonValueKind.Array, messagesProperty.ValueKind);
Assert.Single(messagesProperty.EnumerateArray());
var message = messagesProperty.EnumerateArray().First();
Assert.Equal("TestAuthor", message.GetProperty("authorName").GetString());
Assert.True(message.TryGetProperty("contents", out var contentsProperty));
Assert.Equal(JsonValueKind.Array, contentsProperty.ValueKind);
Assert.Single(contentsProperty.EnumerateArray());
var textContent = contentsProperty.EnumerateArray().First();
Assert.Equal("TestContent", textContent.GetProperty("text").GetString());
}
[Fact]
public void VerifySessionSerializationWithWithStateBag()
{
// Arrange
var session = new ChatClientAgentSession();
session.StateBag.SetValue("dog", new Animal { Name = "Fido" }, TestJsonSerializerContext.Default.Options);
// Act
var json = session.Serialize();
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.True(json.TryGetProperty("stateBag", out var stateBagProperty));
Assert.Equal(JsonValueKind.Object, stateBagProperty.ValueKind);
Assert.True(stateBagProperty.TryGetProperty("dog", out var dogProperty));
Assert.Equal(JsonValueKind.Object, dogProperty.ValueKind);
Assert.True(dogProperty.TryGetProperty("name", out var nameProperty));
Assert.Equal("Fido", nameProperty.GetString());
}
/// <summary>
/// Verify session serialization to JSON with custom options.
/// </summary>
[Fact]
public void VerifySessionSerializationWithCustomOptions()
{
// Arrange
var session = new ChatClientAgentSession();
JsonSerializerOptions options = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
options.TypeInfoResolverChain.Add(AgentJsonUtilities.DefaultOptions.TypeInfoResolver!);
// Act
var json = session.Serialize(options);
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
// [JsonPropertyName] takes precedence over naming policy
Assert.True(json.TryGetProperty("conversationId", out var _));
}
#endregion Serialize Tests
#region StateBag Roundtrip Tests
[Fact]
public void VerifyStateBagRoundtrips()
{
// Arrange
var session = new ChatClientAgentSession();
session.StateBag.SetValue("dog", new Animal { Name = "Fido" }, TestJsonSerializerContext.Default.Options);
// Act
var serializedSession = session.Serialize();
var deserializedSession = ChatClientAgentSession.Deserialize(serializedSession);
// Assert
var dog = deserializedSession.StateBag.GetValue<Animal>("dog", TestJsonSerializerContext.Default.Options);
Assert.NotNull(dog);
Assert.Equal("Fido", dog.Name);
}
#endregion
internal sealed class Animal
{
public string Name { get; set; } = string.Empty;
}
}
@@ -0,0 +1,259 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Shared test helper for <see cref="ChatClientAgent"/> integration tests that verify
/// end-to-end behavior with <see cref="PerServiceCallChatHistoryPersistingChatClient"/> and
/// <see cref="FunctionInvokingChatClient"/>.
/// </summary>
internal static class ChatClientAgentTestHelper
{
/// <summary>
/// Represents an expected service call during a test: an optional input verifier and the response to return.
/// </summary>
/// <param name="Response">The <see cref="ChatResponse"/> the mock service should return for this call.</param>
/// <param name="VerifyInput">Optional callback to verify the messages sent to the service on this call.</param>
#pragma warning disable CA1812 // Instantiated by test classes
public sealed record ServiceCallExpectation(
ChatResponse Response,
Action<List<ChatMessage>>? VerifyInput = null);
#pragma warning restore CA1812
/// <summary>
/// Describes the expected shape of a message in the persisted history for structural comparison.
/// </summary>
/// <param name="Role">The expected role of the message.</param>
/// <param name="TextContains">Optional substring that the message text should contain.</param>
/// <param name="ContentTypes">Optional array of expected <see cref="AIContent"/> types in the message.</param>
#pragma warning disable CA1812 // Instantiated by test classes
public sealed record ExpectedMessage(
ChatRole Role,
string? TextContains = null,
Type[]? ContentTypes = null);
#pragma warning restore CA1812
/// <summary>
/// The result of a RunAsync invocation, containing the response, session, agent,
/// captured service inputs, and call counts for detailed verification.
/// </summary>
public sealed record RunResult(
AgentResponse Response,
ChatClientAgentSession Session,
ChatClientAgent Agent,
Mock<IChatClient> MockService,
int TotalServiceCalls,
List<List<ChatMessage>> CapturedServiceInputs);
/// <summary>
/// Creates a mock <see cref="IChatClient"/> that returns responses in sequence,
/// captures input messages, and optionally verifies inputs.
/// </summary>
/// <param name="expectations">The ordered sequence of expected service calls.</param>
/// <param name="callIndex">Shared call index counter (allows reuse across multiple RunAsync calls).</param>
/// <param name="capturedInputs">List that captured service inputs are appended to.</param>
/// <returns>The configured mock.</returns>
public static Mock<IChatClient> CreateSequentialMock(
List<ServiceCallExpectation> expectations,
Ref<int> callIndex,
List<List<ChatMessage>> capturedInputs)
{
Mock<IChatClient> mock = new();
mock.Setup(s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
{
int idx = callIndex.Value++;
var messageList = msgs.ToList();
capturedInputs.Add(messageList);
if (idx >= expectations.Count)
{
throw new InvalidOperationException(
$"Mock received unexpected service call #{idx + 1}. Only {expectations.Count} call(s) were expected.");
}
var expectation = expectations[idx];
expectation.VerifyInput?.Invoke(messageList);
return Task.FromResult(expectation.Response);
});
return mock;
}
/// <summary>
/// Runs the agent with the given inputs, automatically verifying service call count
/// and optional expected history, and returns the result for further assertions.
/// </summary>
/// <param name="inputMessages">Messages to pass to RunAsync.</param>
/// <param name="serviceCallExpectations">Ordered service call expectations for the mock.</param>
/// <param name="agentOptions">Options for configuring the agent. If null, defaults are used.</param>
/// <param name="existingSession">An existing session to reuse (for multi-turn tests). If null, a new session is created.</param>
/// <param name="existingAgent">An existing agent to reuse (for multi-turn tests). If null, a new agent is created.</param>
/// <param name="existingMock">An existing mock to reuse (for multi-turn tests). If null, a new mock is created.</param>
/// <param name="callIndex">Shared call index for multi-turn tests. If null, a new counter is created.</param>
/// <param name="capturedInputs">Shared captured inputs list for multi-turn tests. If null, a new list is created.</param>
/// <param name="initialChatHistory">Optional initial chat history to pre-populate in <see cref="InMemoryChatHistoryProvider"/>.</param>
/// <param name="runOptions">Optional <see cref="AgentRunOptions"/> to pass to RunAsync.</param>
/// <param name="expectedServiceCallCount">
/// If provided, asserts the total number of service calls matches.
/// For multi-turn tests, pass null and verify after the final turn.
/// </param>
/// <param name="expectedHistory">
/// If provided, asserts that the persisted history matches these expected messages.
/// For multi-turn tests, pass null and verify after the final turn.
/// </param>
/// <returns>A <see cref="RunResult"/> containing the response, session, agent, mock, and captured inputs.</returns>
public static async Task<RunResult> RunAsync(
List<ChatMessage> inputMessages,
List<ServiceCallExpectation> serviceCallExpectations,
ChatClientAgentOptions? agentOptions = null,
ChatClientAgentSession? existingSession = null,
ChatClientAgent? existingAgent = null,
Mock<IChatClient>? existingMock = null,
Ref<int>? callIndex = null,
List<List<ChatMessage>>? capturedInputs = null,
List<ChatMessage>? initialChatHistory = null,
AgentRunOptions? runOptions = null,
int? expectedServiceCallCount = null,
List<ExpectedMessage>? expectedHistory = null)
{
callIndex ??= new Ref<int>(0);
capturedInputs ??= [];
var mock = existingMock ?? CreateSequentialMock(serviceCallExpectations, callIndex, capturedInputs);
agentOptions ??= new ChatClientAgentOptions();
var agent = existingAgent ?? new ChatClientAgent(
mock.Object,
options: agentOptions,
services: new ServiceCollection().BuildServiceProvider());
var session = existingSession ?? (await agent.CreateSessionAsync() as ChatClientAgentSession)!;
// Pre-populate initial chat history if provided.
if (initialChatHistory is not null)
{
(agent.ChatHistoryProvider as InMemoryChatHistoryProvider)
?.SetMessages(session, new List<ChatMessage>(initialChatHistory));
}
var response = await agent.RunAsync(inputMessages, session, runOptions);
var result = new RunResult(response, session, agent, mock, callIndex.Value, capturedInputs);
// Auto-verify service call count if specified.
if (expectedServiceCallCount.HasValue)
{
Assert.Equal(expectedServiceCallCount.Value, callIndex.Value);
}
// Auto-verify persisted history if specified.
if (expectedHistory is not null)
{
var history = GetPersistedHistory(agent, session);
AssertMessagesMatch(history, expectedHistory);
}
return result;
}
/// <summary>
/// Asserts that the actual message list matches the expected message patterns structurally.
/// Checks message count, roles, optional text content, and optional content types.
/// </summary>
/// <param name="actual">The actual messages to verify.</param>
/// <param name="expected">The expected message patterns.</param>
public static void AssertMessagesMatch(List<ChatMessage> actual, List<ExpectedMessage> expected)
{
Assert.True(
expected.Count == actual.Count,
$"Expected {expected.Count} message(s) but found {actual.Count}.\nActual messages:\n{FormatMessages(actual)}");
for (int i = 0; i < expected.Count; i++)
{
var exp = expected[i];
var act = actual[i];
Assert.True(
exp.Role == act.Role,
$"Message [{i}]: expected role {exp.Role} but found {act.Role}.\nActual messages:\n{FormatMessages(actual)}");
if (exp.TextContains is not null)
{
Assert.Contains(exp.TextContains, act.Text, StringComparison.Ordinal);
}
if (exp.ContentTypes is not null)
{
AssertContentTypes(act.Contents, exp.ContentTypes, i);
}
}
}
/// <summary>
/// Gets the persisted chat history from the agent's <see cref="InMemoryChatHistoryProvider"/>.
/// </summary>
/// <param name="agent">The agent whose history provider to query.</param>
/// <param name="session">The session to get history for.</param>
/// <returns>The list of persisted messages, or an empty list if no provider is available.</returns>
public static List<ChatMessage> GetPersistedHistory(ChatClientAgent agent, AgentSession session)
{
var provider = agent.ChatHistoryProvider as InMemoryChatHistoryProvider;
return provider?.GetMessages(session) ?? [];
}
/// <summary>
/// Formats the contents of a message list as a diagnostic string for test failure messages.
/// </summary>
/// <param name="messages">The messages to format.</param>
/// <returns>A human-readable representation of the messages.</returns>
public static string FormatMessages(IEnumerable<ChatMessage> messages)
{
var sb = new StringBuilder();
int index = 0;
foreach (var msg in messages)
{
sb.AppendLine($" [{index}] Role={msg.Role}, Text=\"{msg.Text}\", Contents=[{string.Join(", ", msg.Contents.Select(c => c.GetType().Name))}]");
index++;
}
return sb.ToString();
}
/// <summary>
/// A simple mutable reference wrapper for value types, allowing shared state across callbacks.
/// </summary>
public sealed class Ref<T>(T value) where T : struct
{
public T Value { get; set; } = value;
}
/// <summary>
/// Asserts that a message's content collection contains the expected content types.
/// </summary>
private static void AssertContentTypes(IList<AIContent> contents, Type[] expectedTypes, int messageIndex)
{
Assert.True(
contents.Count >= expectedTypes.Length,
$"Message [{messageIndex}]: expected at least {expectedTypes.Length} content(s) but found {contents.Count}. " +
$"Actual types: [{string.Join(", ", contents.Select(c => c.GetType().Name))}]");
foreach (var expectedType in expectedTypes)
{
Assert.True(
contents.Any(c => expectedType.IsInstanceOfType(c)),
$"Message [{messageIndex}]: expected content of type {expectedType.Name} but found [{string.Join(", ", contents.Select(c => c.GetType().Name))}]");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,306 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests that verify the end-to-end approval flow behavior of the
/// <see cref="ChatClientAgent"/> class with <see cref="PerServiceCallChatHistoryPersistingChatClient"/>,
/// ensuring that chat history is correctly persisted across multi-turn approval interactions.
/// </summary>
public class ChatClientAgent_ApprovalsTests
{
#region Per-Service-Call Persistence Approval Tests
/// <summary>
/// Verifies that with per-service-call persistence and an approval-required tool,
/// a two-turn approval flow persists the correct final history:
/// Turn 1: user asks → model returns FCC → FICC converts to ToolApprovalRequestContent → returned to caller.
/// Turn 2: caller sends ToolApprovalResponseContent → FICC processes approval, invokes function, calls model again.
/// Final history: [user, assistant(FCC), tool(FRC), assistant(final)].
/// </summary>
[Fact]
public async Task RunAsync_ApprovalRequired_PerServiceCallPersistence_PersistsCorrectHistoryAsync()
{
// Arrange
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
var approvalTool = new ApprovalRequiredAIFunction(tool);
var callIndex = new ChatClientAgentTestHelper.Ref<int>(0);
var capturedInputs = new List<List<ChatMessage>>();
var serviceExpectations = new List<ChatClientAgentTestHelper.ServiceCallExpectation>
{
// Turn 1: model returns a function call (FICC will convert to approval request)
new(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
// Turn 2: after approval, FICC invokes the function and calls the model again
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])),
};
// Act — Turn 1: initial request
var result1 = await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "What's the weather?")],
serviceCallExpectations: serviceExpectations,
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
RequirePerServiceCallChatHistoryPersistence = true,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
// Verify Turn 1 returns exactly one approval request
var approvalRequests = result1.Response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.ToList();
Assert.Single(approvalRequests);
Assert.Equal(1, result1.TotalServiceCalls);
// Verify service received user message on first call
Assert.Single(capturedInputs);
Assert.Contains(capturedInputs[0], m => m.Role == ChatRole.User && m.Text == "What's the weather?");
// Act — Turn 2: send approval response
var approvalResponseMessages = approvalRequests.ConvertAll(req =>
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)]));
await ChatClientAgentTestHelper.RunAsync(
inputMessages: approvalResponseMessages,
serviceCallExpectations: serviceExpectations,
existingSession: result1.Session,
existingAgent: result1.Agent,
existingMock: result1.MockService,
callIndex: callIndex,
capturedInputs: capturedInputs,
expectedServiceCallCount: 2,
expectedHistory:
[
new(ChatRole.User, TextContains: "What's the weather?"),
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
]);
// Verify second service call received the full conversation (user + FCC + FRC)
Assert.Equal(2, capturedInputs.Count);
Assert.Contains(capturedInputs[1], m => m.Contents.OfType<FunctionCallContent>().Any());
Assert.Contains(capturedInputs[1], m => m.Contents.OfType<FunctionResultContent>().Any());
}
#endregion
#region End-of-Run Persistence Approval Tests
/// <summary>
/// Verifies that with end-of-run persistence and an approval-required tool,
/// a two-turn approval flow persists the correct final history.
/// </summary>
[Fact]
public async Task RunAsync_ApprovalRequired_EndOfRunPersistence_PersistsCorrectHistoryAsync()
{
// Arrange
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
var approvalTool = new ApprovalRequiredAIFunction(tool);
var callIndex = new ChatClientAgentTestHelper.Ref<int>(0);
var capturedInputs = new List<List<ChatMessage>>();
var serviceExpectations = new List<ChatClientAgentTestHelper.ServiceCallExpectation>
{
new(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])),
};
// Act — Turn 1
var result1 = await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "What's the weather?")],
serviceCallExpectations: serviceExpectations,
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
},
callIndex: callIndex,
capturedInputs: capturedInputs);
var approvalRequests = result1.Response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.ToList();
Assert.Single(approvalRequests);
// Act — Turn 2
var approvalResponseMessages = approvalRequests.ConvertAll(req =>
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)]));
var result2 = await ChatClientAgentTestHelper.RunAsync(
inputMessages: approvalResponseMessages,
serviceCallExpectations: serviceExpectations,
existingSession: result1.Session,
existingAgent: result1.Agent,
existingMock: result1.MockService,
callIndex: callIndex,
capturedInputs: capturedInputs,
expectedServiceCallCount: 2,
expectedHistory:
[
// End-of-run persistence retains the approval request from Turn 1
// and the approval response from Turn 2
new(ChatRole.User, TextContains: "What's the weather?"),
new(ChatRole.Assistant, ContentTypes: [typeof(ToolApprovalRequestContent)]),
new(ChatRole.User, ContentTypes: [typeof(ToolApprovalResponseContent)]),
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
]);
}
#endregion
#region Service-Stored History Approval Tests
/// <summary>
/// Verifies that with service-stored history (ConversationId returned) and an approval-required tool,
/// the two-turn approval flow completes without errors and the session gets the ConversationId.
/// </summary>
[Fact]
public async Task RunAsync_ApprovalRequired_ServiceStoredHistory_CompletesWithoutErrorAsync()
{
// Arrange
const string ConversationId = "thread-456";
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
var approvalTool = new ApprovalRequiredAIFunction(tool);
var callIndex = new ChatClientAgentTestHelper.Ref<int>(0);
var capturedInputs = new List<List<ChatMessage>>();
var serviceExpectations = new List<ChatClientAgentTestHelper.ServiceCallExpectation>
{
new(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])
{
ConversationId = ConversationId,
}),
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])
{
ConversationId = ConversationId,
}),
};
// Act — Turn 1
var result1 = await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "What's the weather?")],
serviceCallExpectations: serviceExpectations,
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
},
callIndex: callIndex,
capturedInputs: capturedInputs);
var approvalRequests = result1.Response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.ToList();
Assert.Single(approvalRequests);
Assert.Equal(ConversationId, result1.Session.ConversationId);
// Act — Turn 2
var approvalResponseMessages = approvalRequests.ConvertAll(req =>
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)]));
var result2 = await ChatClientAgentTestHelper.RunAsync(
inputMessages: approvalResponseMessages,
serviceCallExpectations: serviceExpectations,
existingSession: result1.Session,
existingAgent: result1.Agent,
existingMock: result1.MockService,
callIndex: callIndex,
capturedInputs: capturedInputs,
expectedServiceCallCount: 2);
// Assert — session should retain the ConversationId, response should be correct
Assert.Equal(ConversationId, result2.Session.ConversationId);
Assert.Contains(result2.Response.Messages, m => m.Text == "The weather in Amsterdam is sunny and 22°C.");
}
#endregion
#region Approval Rejected Tests
/// <summary>
/// Verifies that when an approval is rejected, the rejection result is persisted in the history
/// and the model receives the rejection information.
/// </summary>
[Fact]
public async Task RunAsync_ApprovalRejected_PersistsRejectionInHistoryAsync()
{
// Arrange
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
var approvalTool = new ApprovalRequiredAIFunction(tool);
var callIndex = new ChatClientAgentTestHelper.Ref<int>(0);
var capturedInputs = new List<List<ChatMessage>>();
var serviceExpectations = new List<ChatClientAgentTestHelper.ServiceCallExpectation>
{
// Turn 1: model requests function call
new(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
// Turn 2: after rejection, model gets the rejection info and responds accordingly
new(new ChatResponse([new(ChatRole.Assistant, "I'm sorry, I cannot check the weather without your approval.")])),
};
// Act — Turn 1
var result1 = await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "What's the weather?")],
serviceCallExpectations: serviceExpectations,
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
RequirePerServiceCallChatHistoryPersistence = true,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
var approvalRequests = result1.Response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.ToList();
Assert.Single(approvalRequests);
// Act — Turn 2: reject the approval
var rejectionMessages = approvalRequests.ConvertAll(req =>
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: false, reason: "User declined")]));
var result2 = await ChatClientAgentTestHelper.RunAsync(
inputMessages: rejectionMessages,
serviceCallExpectations: serviceExpectations,
existingSession: result1.Session,
existingAgent: result1.Agent,
existingMock: result1.MockService,
callIndex: callIndex,
capturedInputs: capturedInputs,
expectedServiceCallCount: 2);
// Assert — history should contain the rejection result (FRC with rejection)
var history = ChatClientAgentTestHelper.GetPersistedHistory(result2.Agent, result2.Session);
Assert.True(
history.Count >= 3,
$"Expected at least 3 messages in history, got {history.Count}.\n{ChatClientAgentTestHelper.FormatMessages(history)}");
Assert.Contains(history, m => m.Role == ChatRole.User && m.Text == "What's the weather?");
Assert.Contains(history, m => m.Contents.OfType<FunctionResultContent>().Any(
frc => frc.Result?.ToString()?.Contains("rejected") == true));
Assert.Contains(history, m => m.Role == ChatRole.Assistant &&
m.Text == "I'm sorry, I cannot check the weather without your approval.");
// Verify the second service call received the rejection FRC
Assert.Equal(2, capturedInputs.Count);
Assert.Contains(capturedInputs[1], m => m.Contents.OfType<FunctionResultContent>().Any(
frc => frc.Result?.ToString()?.Contains("rejected") == true));
}
#endregion
}
@@ -0,0 +1,833 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests for ChatClientAgent background responses functionality.
/// </summary>
public class ChatClientAgent_BackgroundResponsesTests
{
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RunAsync_PropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions)
{
// Arrange
var continuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }));
ChatOptions? capturedChatOptions = null;
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ContinuationToken = null, ConversationId = "conversation-id" });
AgentRunOptions agentRunOptions;
if (providePropsViaChatOptions)
{
ChatOptions chatOptions = new()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken
};
agentRunOptions = new ChatClientAgentRunOptions(chatOptions);
}
else
{
agentRunOptions = new AgentRunOptions()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken
};
}
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentSession? session = new() { ConversationId = "conversation-id" };
// Act
await agent.RunAsync(session, options: agentRunOptions);
// Assert
Assert.NotNull(capturedChatOptions);
Assert.True(capturedChatOptions.AllowBackgroundResponses);
Assert.Same(continuationToken.InnerToken, capturedChatOptions.ContinuationToken);
}
[Fact]
public async Task RunAsync_WhenPropertiesSetInBothLocations_PrioritizesAgentRunOptionsOverChatOptionsAsync()
{
// Arrange
var continuationToken1 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }));
var continuationToken2 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }));
ChatOptions? capturedChatOptions = null;
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ContinuationToken = null, ConversationId = "conversation-id" });
ChatOptions chatOptions = new()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken1
};
ChatClientAgentRunOptions agentRunOptions = new(chatOptions)
{
AllowBackgroundResponses = false,
ContinuationToken = continuationToken2
};
ChatClientAgentSession? session = new() { ConversationId = "conversation-id" };
ChatClientAgent agent = new(mockChatClient.Object);
// Act
await agent.RunAsync(session, options: agentRunOptions);
// Assert
Assert.NotNull(capturedChatOptions);
Assert.False(capturedChatOptions.AllowBackgroundResponses);
Assert.Same(continuationToken2.InnerToken, capturedChatOptions.ContinuationToken);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RunStreamingAsync_PropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions)
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh") { ConversationId = "conversation-id" },
new ChatResponseUpdate(role: ChatRole.Assistant, content: "at?") { ConversationId = "conversation-id" },
];
var continuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] };
ChatOptions? capturedChatOptions = null;
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co)
.Returns(ToAsyncEnumerableAsync(returnUpdates));
AgentRunOptions agentRunOptions;
if (providePropsViaChatOptions)
{
ChatOptions chatOptions = new()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken
};
agentRunOptions = new ChatClientAgentRunOptions(chatOptions);
}
else
{
agentRunOptions = new AgentRunOptions()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken
};
}
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentSession? session = new() { ConversationId = "conversation-id" };
// Act
await foreach (var _ in agent.RunStreamingAsync(session, options: agentRunOptions))
{
}
// Assert
Assert.NotNull(capturedChatOptions);
Assert.True(capturedChatOptions.AllowBackgroundResponses);
Assert.Same(continuationToken.InnerToken, capturedChatOptions.ContinuationToken);
}
[Fact]
public async Task RunStreamingAsync_WhenPropertiesSetInBothLocations_PrioritizesAgentRunOptionsOverChatOptionsAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh") { ConversationId = "conversation-id" },
];
var continuationToken1 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] };
var continuationToken2 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] };
ChatOptions? capturedChatOptions = null;
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co)
.Returns(ToAsyncEnumerableAsync(returnUpdates));
ChatOptions chatOptions = new()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken1
};
ChatClientAgentRunOptions agentRunOptions = new(chatOptions)
{
AllowBackgroundResponses = false,
ContinuationToken = continuationToken2
};
ChatClientAgent agent = new(mockChatClient.Object);
var session = new ChatClientAgentSession() { ConversationId = "conversation-id" };
// Act
await foreach (var _ in agent.RunStreamingAsync(session, options: agentRunOptions))
{
}
// Assert
Assert.NotNull(capturedChatOptions);
Assert.False(capturedChatOptions.AllowBackgroundResponses);
Assert.Same(continuationToken2.InnerToken, capturedChatOptions.ContinuationToken);
}
[Fact]
public async Task RunAsync_WhenContinuationTokenReceivedFromChatResponse_WrapsContinuationTokenAsync()
{
// Arrange
var continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "partial")]) { ContinuationToken = continuationToken });
ChatClientAgent agent = new(mockChatClient.Object);
var runOptions = new ChatClientAgentRunOptions(new ChatOptions { AllowBackgroundResponses = true });
ChatClientAgentSession? session = new();
// Act
var response = await agent.RunAsync([new(ChatRole.User, "hi")], session, options: runOptions);
// Assert
Assert.Same(continuationToken, (response.ContinuationToken as ChatClientAgentContinuationToken)?.InnerToken);
}
[Fact]
public async Task RunStreamingAsync_WhenContinuationTokenReceived_WrapsContinuationTokenAsync()
{
// Arrange
var token1 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
ChatResponseUpdate[] expectedUpdates =
[
new ChatResponseUpdate(ChatRole.Assistant, "pa") { ContinuationToken = token1 },
new ChatResponseUpdate(ChatRole.Assistant, "rt") { ContinuationToken = null } // terminal
];
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions?>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(expectedUpdates));
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentSession? session = new();
// Act
var actualUpdates = new List<AgentResponseUpdate>();
await foreach (var u in agent.RunStreamingAsync([new(ChatRole.User, "hi")], session, options: new ChatClientAgentRunOptions(new ChatOptions { AllowBackgroundResponses = true })))
{
actualUpdates.Add(u);
}
// Assert
Assert.Equal(2, actualUpdates.Count);
Assert.Same(token1, (actualUpdates[0].ContinuationToken as ChatClientAgentContinuationToken)?.InnerToken);
Assert.Null(actualUpdates[1].ContinuationToken); // last update has null token
}
[Fact]
public async Task RunAsync_WhenMessagesProvidedWithContinuationToken_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
ChatClientAgent agent = new(mockChatClient.Object);
AgentRunOptions runOptions = new() { ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) };
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync(inputMessages, options: runOptions));
// Verify that the IChatClient was never called due to early validation
mockChatClient.Verify(
c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task RunStreamingAsync_WhenMessagesProvidedWithContinuationToken_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
ChatClientAgent agent = new(mockChatClient.Object);
AgentRunOptions runOptions = new() { ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) };
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var update in agent.RunStreamingAsync(inputMessages, options: runOptions))
{
// Should not reach here
}
});
// Verify that the IChatClient was never called due to early validation
mockChatClient.Verify(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task RunAsync_WhenContinuationTokenProvided_SkipsSessionMessagePopulationAsync()
{
// Arrange
List<ChatMessage> capturedMessages = [];
// Create a mock chat history provider that would normally provide messages
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["ChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.ReturnsAsync([new(ChatRole.User, "Message from chat history provider")]);
// Create a mock AI context provider that would normally provide context
var mockContextProvider = new Mock<AIContextProvider>(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["Provider1"]);
mockContextProvider
.Protected()
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new AIContext
{
Messages = [new(ChatRole.System, "Message from AI context")],
Instructions = "context instructions"
});
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedMessages.AddRange(msgs))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "continued response")]));
ChatClientAgent agent = new(mockChatClient.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
AIContextProviders = [mockContextProvider.Object]
});
// Create a session
ChatClientAgentSession? session = new();
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
};
// Act
await agent.RunAsync([], session, options: runOptions);
// Assert
// With continuation token, session message population should be skipped
Assert.Empty(capturedMessages);
// Verify that chat history provider was never called due to continuation token
mockChatHistoryProvider
.Protected()
.Verify<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", Times.Never(), ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>());
// Verify that AI context provider was never called due to continuation token
mockContextProvider
.Protected()
.Verify<ValueTask<AIContext>>("InvokingCoreAsync", Times.Never(), ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>());
}
[Fact]
public async Task RunStreamingAsync_WhenContinuationTokenProvided_SkipsSessionMessagePopulationAsync()
{
// Arrange
List<ChatMessage> capturedMessages = [];
// Create a mock chat history provider that would normally provide messages
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["ChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.ReturnsAsync([new(ChatRole.User, "Message from chat history provider")]);
// Create a mock AI context provider that would normally provide context
var mockContextProvider = new Mock<AIContextProvider>(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["Provider1"]);
mockContextProvider
.Protected()
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new AIContext
{
Messages = [new(ChatRole.System, "Message from AI context")],
Instructions = "context instructions"
});
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedMessages.AddRange(msgs))
.Returns(ToAsyncEnumerableAsync([new ChatResponseUpdate(role: ChatRole.Assistant, content: "continued response")]));
ChatClientAgent agent = new(mockChatClient.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
AIContextProviders = [mockContextProvider.Object]
});
// Create a session
ChatClientAgentSession? session = new();
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] }
};
// Act
await agent.RunStreamingAsync(session, options: runOptions).ToListAsync();
// Assert
// With continuation token, session message population should be skipped
Assert.Empty(capturedMessages);
// Verify that chat history provider was never called due to continuation token
mockChatHistoryProvider
.Protected()
.Verify<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", Times.Never(), ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>());
// Verify that AI context provider was never called due to continuation token
mockContextProvider
.Protected()
.Verify<ValueTask<AIContext>>("InvokingCoreAsync", Times.Never(), ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>());
}
[Fact]
public async Task RunAsync_WhenNoSessionProvidedForBackgroundResponses_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
ChatClientAgent agent = new(mockChatClient.Object);
AgentRunOptions runOptions = new() { AllowBackgroundResponses = true };
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync(inputMessages, options: runOptions));
// Verify that the IChatClient was never called due to early validation
mockChatClient.Verify(
c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task RunStreamingAsync_WhenNoSessionProvidedForBackgroundResponses_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
ChatClientAgent agent = new(mockChatClient.Object);
AgentRunOptions runOptions = new() { AllowBackgroundResponses = true };
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var update in agent.RunStreamingAsync(inputMessages, options: runOptions))
{
// Should not reach here
}
});
// Verify that the IChatClient was never called due to early validation
mockChatClient.Verify(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task RunStreamingAsync_WhenInputMessagesPresentInContinuationToken_ResumesStreamingAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "continuation") { ConversationId = "conversation-id" },
];
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(returnUpdates));
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentSession? session = new() { ConversationId = "conversation-id" };
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
{
InputMessages = [new ChatMessage(ChatRole.User, "previous message")]
}
};
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(session, options: runOptions))
{
updates.Add(update);
}
// Assert
Assert.Single(updates);
// Verify that the IChatClient was called
mockChatClient.Verify(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_WhenResponseUpdatesPresentInContinuationToken_ResumesStreamingAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "continuation") { ConversationId = "conversation-id" },
];
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(returnUpdates));
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentSession? session = new() { ConversationId = "conversation-id" };
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
{
ResponseUpdates = [new ChatResponseUpdate(ChatRole.Assistant, "previous update")]
}
};
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(session, options: runOptions))
{
updates.Add(update);
}
// Assert
Assert.Single(updates);
// Verify that the IChatClient was called
mockChatClient.Verify(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_WhenResumingStreaming_UsesUpdatesFromInitialRunForContextProviderAndChatHistoryProviderAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "upon"),
new ChatResponseUpdate(role: ChatRole.Assistant, content: " a"),
new ChatResponseUpdate(role: ChatRole.Assistant, content: " time"),
];
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(returnUpdates));
List<ChatMessage> capturedMessagesAddedToProvider = [];
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["ChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Callback<ChatHistoryProvider.InvokedContext, CancellationToken>((ctx, ct) => capturedMessagesAddedToProvider.AddRange(ctx.ResponseMessages ?? []))
.Returns(new ValueTask());
AIContextProvider.InvokedContext? capturedInvokedContext = null;
var mockContextProvider = new Mock<AIContextProvider>(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["Provider1"]);
mockContextProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Callback<AIContextProvider.InvokedContext, CancellationToken>((context, ct) => capturedInvokedContext = context)
.Returns(new ValueTask());
ChatClientAgent agent = new(mockChatClient.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
AIContextProviders = [mockContextProvider.Object]
});
ChatClientAgentSession? session = new();
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
{
ResponseUpdates = [new ChatResponseUpdate(ChatRole.Assistant, "once ")]
}
};
// Act
await agent.RunStreamingAsync(session, options: runOptions).ToListAsync();
// Assert
mockChatHistoryProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(), ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>());
Assert.Single(capturedMessagesAddedToProvider);
Assert.Contains("once upon a time", capturedMessagesAddedToProvider[0].Text);
mockContextProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(), ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>());
Assert.NotNull(capturedInvokedContext?.ResponseMessages);
Assert.Single(capturedInvokedContext.ResponseMessages);
Assert.Contains("once upon a time", capturedInvokedContext.ResponseMessages.ElementAt(0).Text);
}
[Fact]
public async Task RunStreamingAsync_WhenResumingStreaming_UsesInputMessagesFromInitialRunForContextProviderAndChatHistoryProviderAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(Array.Empty<ChatResponseUpdate>()));
List<ChatMessage> capturedMessagesAddedToProvider = [];
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["ChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Callback<ChatHistoryProvider.InvokedContext, CancellationToken>((ctx, ct) => capturedMessagesAddedToProvider.AddRange(ctx.RequestMessages))
.Returns(new ValueTask());
AIContextProvider.InvokedContext? capturedInvokedContext = null;
var mockContextProvider = new Mock<AIContextProvider>(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["Provider1"]);
mockContextProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Callback<AIContextProvider.InvokedContext, CancellationToken>((context, ct) => capturedInvokedContext = context)
.Returns(new ValueTask());
ChatClientAgent agent = new(mockChatClient.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
AIContextProviders = [mockContextProvider.Object]
});
ChatClientAgentSession? session = new();
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
{
InputMessages = [new ChatMessage(ChatRole.User, "Tell me a story")],
}
};
// Act
await agent.RunStreamingAsync(session, options: runOptions).ToListAsync();
// Assert
mockChatHistoryProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(), ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>());
Assert.Single(capturedMessagesAddedToProvider);
Assert.Contains("Tell me a story", capturedMessagesAddedToProvider[0].Text);
mockContextProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(), ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>());
Assert.NotNull(capturedInvokedContext?.RequestMessages);
Assert.Single(capturedInvokedContext.RequestMessages);
Assert.Contains("Tell me a story", capturedInvokedContext.RequestMessages.ElementAt(0).Text);
}
[Fact]
public async Task RunStreamingAsync_WhenResumingStreaming_SavesInputMessagesAndUpdatesInContinuationTokenAsync()
{
// Arrange
List<ChatResponseUpdate> returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "Once") { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
new ChatResponseUpdate(role: ChatRole.Assistant, content: " upon") { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
new ChatResponseUpdate(role: ChatRole.Assistant, content: " a") { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
new ChatResponseUpdate(role: ChatRole.Assistant, content: " time"){ ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
];
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(returnUpdates));
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentSession? session = new() { };
List<ChatClientAgentContinuationToken> capturedContinuationTokens = [];
ChatMessage userMessage = new(ChatRole.User, "Tell me a story");
// Act
// Do the initial run
await foreach (var update in agent.RunStreamingAsync(userMessage, session))
{
capturedContinuationTokens.Add(Assert.IsType<ChatClientAgentContinuationToken>(update.ContinuationToken));
break;
}
// Now resume the run using the captured continuation token
returnUpdates.RemoveAt(0); // remove the first mock update as it was already processed
var options = new AgentRunOptions { ContinuationToken = capturedContinuationTokens[0] };
await foreach (var update in agent.RunStreamingAsync(session, options: options))
{
capturedContinuationTokens.Add(Assert.IsType<ChatClientAgentContinuationToken>(update.ContinuationToken));
}
// Assert
Assert.Equal(4, capturedContinuationTokens.Count);
// Verify that the first continuation token has the initial input and first update
Assert.NotNull(capturedContinuationTokens[0].InputMessages);
Assert.Single(capturedContinuationTokens[0].InputMessages!);
Assert.Equal("Tell me a story", capturedContinuationTokens[0].InputMessages!.Last().Text);
Assert.NotNull(capturedContinuationTokens[0].ResponseUpdates);
Assert.Single(capturedContinuationTokens[0].ResponseUpdates!);
Assert.Equal("Once", capturedContinuationTokens[0].ResponseUpdates![0].Text);
// Verify the last continuation token has the input and all updates
var lastToken = capturedContinuationTokens[^1];
Assert.NotNull(lastToken.InputMessages);
Assert.Single(lastToken.InputMessages!);
Assert.Equal("Tell me a story", lastToken.InputMessages!.Last().Text);
Assert.NotNull(lastToken.ResponseUpdates);
Assert.Equal(4, lastToken.ResponseUpdates!.Count);
Assert.Equal("Once", lastToken.ResponseUpdates!.ElementAt(0).Text);
Assert.Equal(" upon", lastToken.ResponseUpdates!.ElementAt(1).Text);
Assert.Equal(" a", lastToken.ResponseUpdates!.ElementAt(2).Text);
Assert.Equal(" time", lastToken.ResponseUpdates!.ElementAt(3).Text);
}
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> values)
{
await Task.Yield();
foreach (var update in values)
{
yield return update;
}
}
}
@@ -0,0 +1,654 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
using Xunit.Sdk;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests that verify the chat history management functionality of the <see cref="ChatClientAgent"/> class,
/// e.g. that it correctly reads and updates chat history in any available <see cref="ChatHistoryProvider"/> or that
/// it uses conversation id correctly for service managed chat history.
/// </summary>
public class ChatClientAgent_ChatHistoryManagementTests
{
#region ConversationId Tests
/// <summary>
/// Verify that RunAsync does not throw when providing a ConversationId via both AgentSession and
/// via ChatOptions and the two are the same.
/// </summary>
[Fact]
public async Task RunAsync_DoesNotThrow_WhenSpecifyingTwoSameConversationIdsAsync()
{
// Arrange
var chatOptions = new ChatOptions { ConversationId = "ConvId" };
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.Is<ChatOptions>(opts => opts.ConversationId == "ConvId"),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
ChatClientAgentSession? session = new() { ConversationId = "ConvId" };
// Act & Assert
var response = await agent.RunAsync([new(ChatRole.User, "test")], session, options: new ChatClientAgentRunOptions(chatOptions));
Assert.NotNull(response);
}
/// <summary>
/// Verify that RunAsync throws when providing a ConversationId via both AgentSession and
/// via ChatOptions and the two are different.
/// </summary>
[Fact]
public async Task RunAsync_Throws_WhenSpecifyingTwoDifferentConversationIdsAsync()
{
// Arrange
var chatOptions = new ChatOptions { ConversationId = "ConvId" };
Mock<IChatClient> mockService = new();
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
ChatClientAgentSession? session = new() { ConversationId = "ThreadId" };
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session, options: new ChatClientAgentRunOptions(chatOptions)));
}
/// <summary>
/// Verify that RunAsync clones the ChatOptions when providing a session with a ConversationId and a ChatOptions.
/// </summary>
[Fact]
public async Task RunAsync_ClonesChatOptions_ToAddConversationIdAsync()
{
// Arrange
var chatOptions = new ChatOptions { MaxOutputTokens = 100 };
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.Is<ChatOptions>(opts => opts.MaxOutputTokens == 100 && opts.ConversationId == "ConvId"),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
ChatClientAgentSession? session = new() { ConversationId = "ConvId" };
// Act
await agent.RunAsync([new(ChatRole.User, "test")], session, options: new ChatClientAgentRunOptions(chatOptions));
// Assert
Assert.Null(chatOptions.ConversationId);
}
/// <summary>
/// Verify that RunAsync throws if a session is provided that uses a conversation id already, but the service does not return one on invoke.
/// </summary>
[Fact]
public async Task RunAsync_Throws_ForMissingConversationIdWithConversationIdSessionAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
ChatClientAgentSession? session = new() { ConversationId = "ConvId" };
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
}
/// <summary>
/// Verify that RunAsync sets the ConversationId on the session when the service returns one.
/// </summary>
[Fact]
public async Task RunAsync_SetsConversationIdOnSession_WhenReturnedByChatClientAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
ChatClientAgentSession? session = new();
// Act
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert
Assert.Equal("ConvId", session.ConversationId);
}
#endregion
#region ChatHistoryProvider Tests
/// <summary>
/// Verify that RunAsync uses the default InMemoryChatHistoryProvider when the chat client returns no conversation id.
/// </summary>
[Fact]
public async Task RunAsync_UsesDefaultInMemoryChatHistoryProvider_WhenNoConversationIdReturnedByChatClientAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test instructions" },
});
// Act
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert
var inMemoryProvider = agent.ChatHistoryProvider as InMemoryChatHistoryProvider;
Assert.NotNull(inMemoryProvider);
var messages = inMemoryProvider.GetMessages(session!);
Assert.Equal(2, messages.Count);
Assert.Equal("test", messages[0].Text);
Assert.Equal("response", messages[1].Text);
}
/// <summary>
/// Verify that RunAsync uses the ChatHistoryProvider when the chat client returns no conversation id.
/// </summary>
[Fact]
public async Task RunAsync_UsesChatHistoryProvider_WhenProvidedAndNoConversationIdReturnedByChatClientAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(new List<ChatMessage> { new(ChatRole.User, "Existing Chat History") }.Concat(ctx.RequestMessages).ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test instructions" },
ChatHistoryProvider = mockChatHistoryProvider.Object
});
// Act
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert
Assert.Same(mockChatHistoryProvider.Object, agent.ChatHistoryProvider);
mockService.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Count() == 2 && msgs.Any(m => m.Text == "Existing Chat History") && msgs.Any(m => m.Text == "test")),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
mockChatHistoryProvider
.Protected()
.Verify<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", Times.Once(),
ItExpr.Is<ChatHistoryProvider.InvokingContext>(x => x.RequestMessages.Count() == 1),
ItExpr.IsAny<CancellationToken>());
mockChatHistoryProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x => x.RequestMessages.Count() == 2 && x.ResponseMessages!.Count() == 1),
ItExpr.IsAny<CancellationToken>());
}
/// <summary>
/// Verify that RunAsync notifies the ChatHistoryProvider on failure.
/// </summary>
[Fact]
public async Task RunAsync_NotifiesChatHistoryProvider_OnFailureAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Throws(new InvalidOperationException("Test Error"));
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test instructions" },
ChatHistoryProvider = mockChatHistoryProvider.Object
});
// Act
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
// Assert
Assert.Same(mockChatHistoryProvider.Object, agent.ChatHistoryProvider);
mockChatHistoryProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x => x.RequestMessages.Count() == 1 && x.ResponseMessages == null && x.InvokeException!.Message == "Test Error"),
ItExpr.IsAny<CancellationToken>());
}
/// <summary>
/// Verify that RunAsync throws when a ChatHistoryProvider is provided and the chat client returns a conversation id.
/// </summary>
[Fact]
public async Task RunAsync_Throws_WhenChatHistoryProviderProvidedAndConversationIdReturnedByChatClientAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test instructions" },
ChatHistoryProvider = new InMemoryChatHistoryProvider()
});
// Act & Assert
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
InvalidOperationException exception = await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
Assert.Equal("Only ConversationId or ChatHistoryProvider may be used, but not both. The service returned a conversation id indicating server-side chat history management, but the agent has a ChatHistoryProvider configured.", exception.Message);
}
/// <summary>
/// Verify that RunAsync clears the ChatHistoryProvider when ThrowOnChatHistoryProviderConflict is false
/// and ClearOnChatHistoryProviderConflict is true.
/// </summary>
[Fact]
public async Task RunAsync_ClearsChatHistoryProvider_WhenThrowDisabledAndClearEnabledAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test instructions" },
ChatHistoryProvider = new InMemoryChatHistoryProvider(),
ThrowOnChatHistoryProviderConflict = false,
ClearOnChatHistoryProviderConflict = true,
});
// Act
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert
Assert.Null(agent.ChatHistoryProvider);
Assert.Equal("ConvId", session!.ConversationId);
}
/// <summary>
/// Verify that RunAsync does not throw and does not clear the ChatHistoryProvider when both
/// ThrowOnChatHistoryProviderConflict and ClearOnChatHistoryProviderConflict are false.
/// </summary>
[Fact]
public async Task RunAsync_KeepsChatHistoryProvider_WhenThrowAndClearDisabledAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
var chatHistoryProvider = new InMemoryChatHistoryProvider();
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test instructions" },
ChatHistoryProvider = chatHistoryProvider,
ThrowOnChatHistoryProviderConflict = false,
ClearOnChatHistoryProviderConflict = false,
WarnOnChatHistoryProviderConflict = false,
});
// Act
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert
Assert.Same(chatHistoryProvider, agent.ChatHistoryProvider);
Assert.Equal("ConvId", session!.ConversationId);
}
/// <summary>
/// Verify that RunAsync still throws when ThrowOnChatHistoryProviderConflict is true
/// even if ClearOnChatHistoryProviderConflict is also true (throw takes precedence).
/// </summary>
[Fact]
public async Task RunAsync_Throws_WhenThrowEnabledRegardlessOfClearSettingAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test instructions" },
ChatHistoryProvider = new InMemoryChatHistoryProvider(),
ThrowOnChatHistoryProviderConflict = true,
ClearOnChatHistoryProviderConflict = true,
});
// Act & Assert
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
}
/// <summary>
/// Verify that RunAsync does not throw when no ChatHistoryProvider is configured on options,
/// even if the service returns a conversation id (default InMemoryChatHistoryProvider is used but not from options).
/// </summary>
[Fact]
public async Task RunAsync_DoesNotThrow_WhenNoChatHistoryProviderInOptionsAndConversationIdReturnedAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test instructions" },
});
// Act
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert - no exception, session gets the conversation id
Assert.Equal("ConvId", session!.ConversationId);
}
#endregion
#region ChatHistoryProvider Override Tests
/// <summary>
/// Tests that RunAsync uses an override ChatHistoryProvider provided via AdditionalProperties instead of the provider from a factory
/// if one is supplied.
/// </summary>
[Fact]
public async Task RunAsync_UsesOverrideChatHistoryProvider_WhenProvidedViaAdditionalPropertiesAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
// Arrange a chat history provider to override the factory provided one.
Mock<ChatHistoryProvider> mockOverrideChatHistoryProvider = new(null, null, null);
mockOverrideChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockOverrideChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(new List<ChatMessage> { new(ChatRole.User, "Existing Chat History") }.Concat(ctx.RequestMessages).ToList()));
mockOverrideChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
// Arrange a chat history provider to provide to the agent at construction time.
// This one shouldn't be used since it is being overridden.
Mock<ChatHistoryProvider> mockAgentOptionsChatHistoryProvider = new(null, null, null);
mockAgentOptionsChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockAgentOptionsChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(FailException.ForFailure("Base ChatHistoryProvider shouldn't be used."));
mockAgentOptionsChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Throws(FailException.ForFailure("Base ChatHistoryProvider shouldn't be used."));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test instructions" },
ChatHistoryProvider = mockAgentOptionsChatHistoryProvider.Object
});
// Act
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
AdditionalPropertiesDictionary additionalProperties = new();
additionalProperties.Add(mockOverrideChatHistoryProvider.Object);
await agent.RunAsync([new(ChatRole.User, "test")], session, options: new AgentRunOptions { AdditionalProperties = additionalProperties });
// Assert
Assert.Same(mockAgentOptionsChatHistoryProvider.Object, agent.ChatHistoryProvider);
mockService.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Count() == 2 && msgs.Any(m => m.Text == "Existing Chat History") && msgs.Any(m => m.Text == "test")),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
mockOverrideChatHistoryProvider
.Protected()
.Verify<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", Times.Once(),
ItExpr.Is<ChatHistoryProvider.InvokingContext>(x => x.RequestMessages.Count() == 1),
ItExpr.IsAny<CancellationToken>());
mockOverrideChatHistoryProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x => x.RequestMessages.Count() == 2 && x.ResponseMessages!.Count() == 1),
ItExpr.IsAny<CancellationToken>());
mockAgentOptionsChatHistoryProvider
.Protected()
.Verify<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", Times.Never(),
ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(),
ItExpr.IsAny<CancellationToken>());
mockAgentOptionsChatHistoryProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Never(),
ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(),
ItExpr.IsAny<CancellationToken>());
}
#endregion
#region End-to-End Chat History Persistence Tests
/// <summary>
/// Verifies that with per-service-call persistence (default), a simple request/response
/// results in the correct chat history being persisted: [user, assistant].
/// </summary>
[Fact]
public async Task RunAsync_PerServiceCallPersistence_SimpleResponse_PersistsCorrectHistoryAsync()
{
// Arrange & Act & Assert
await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "Hello")],
serviceCallExpectations:
[
new(new ChatResponse([new(ChatRole.Assistant, "Hi there")])),
],
agentOptions: new()
{
ChatOptions = new() { Instructions = "Be helpful" },
RequirePerServiceCallChatHistoryPersistence = true,
},
expectedServiceCallCount: 1,
expectedHistory:
[
new(ChatRole.User, TextContains: "Hello"),
new(ChatRole.Assistant, TextContains: "Hi there"),
]);
}
/// <summary>
/// Verifies that with per-service-call persistence and a function calling loop,
/// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)].
/// </summary>
[Fact]
public async Task RunAsync_PerServiceCallPersistence_FunctionCallingLoop_PersistsCorrectHistoryAsync()
{
// Arrange
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
// Act & Assert
await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "What's the weather?")],
serviceCallExpectations:
[
// First call: model requests a function call
new(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
// Second call: model returns final response after seeing function result
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])),
],
agentOptions: new()
{
ChatOptions = new() { Tools = [tool] },
RequirePerServiceCallChatHistoryPersistence = true,
},
expectedServiceCallCount: 2,
expectedHistory:
[
new(ChatRole.User, TextContains: "What's the weather?"),
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
]);
}
/// <summary>
/// Verifies that with end-of-run persistence, a simple request/response
/// results in the correct chat history being persisted: [user, assistant].
/// </summary>
[Fact]
public async Task RunAsync_EndOfRunPersistence_SimpleResponse_PersistsCorrectHistoryAsync()
{
// Arrange & Act & Assert
await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "Hello")],
serviceCallExpectations:
[
new(new ChatResponse([new(ChatRole.Assistant, "Hi there")])),
],
agentOptions: new()
{
ChatOptions = new() { Instructions = "Be helpful" },
},
expectedServiceCallCount: 1,
expectedHistory:
[
new(ChatRole.User, TextContains: "Hello"),
new(ChatRole.Assistant, TextContains: "Hi there"),
]);
}
/// <summary>
/// Verifies that with end-of-run persistence and a function calling loop,
/// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)].
/// </summary>
[Fact]
public async Task RunAsync_EndOfRunPersistence_FunctionCallingLoop_PersistsCorrectHistoryAsync()
{
// Arrange
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
// Act & Assert
await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "What's the weather?")],
serviceCallExpectations:
[
new(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])),
],
agentOptions: new()
{
ChatOptions = new() { Tools = [tool] },
},
expectedServiceCallCount: 2,
expectedHistory:
[
new(ChatRole.User, TextContains: "What's the weather?"),
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
]);
}
/// <summary>
/// Verifies that when the service returns a ConversationId (service-stored history),
/// the session gets the ConversationId and no errors occur during the run.
/// </summary>
[Fact]
public async Task RunAsync_ServiceStoredHistory_SetsConversationIdAndCompletesWithoutErrorAsync()
{
// Arrange & Act
var result = await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "Hello")],
serviceCallExpectations:
[
new(new ChatResponse([new(ChatRole.Assistant, "Hi there")]) { ConversationId = "thread-123" }),
],
agentOptions: new()
{
ChatOptions = new() { Instructions = "Be helpful" },
},
expectedServiceCallCount: 1);
// Assert — session should have the conversation id from the service
Assert.Equal("thread-123", result.Session.ConversationId);
Assert.Contains(result.Response.Messages, m => m.Text == "Hi there");
}
#endregion
}
@@ -0,0 +1,552 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains tests for <see cref="ChatOptions"/> merging in <see cref="ChatClientAgent"/>.
/// </summary>
public class ChatClientAgent_ChatOptionsMergingTests
{
/// <summary>
/// Verify that ChatOptions merging works when agent has ChatOptions but request doesn't.
/// </summary>
[Fact]
public async Task ChatOptionsMergingUsesAgentOptionsWhenRequestHasNoneAsync()
{
// Arrange
var agentChatOptions = new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f, Instructions = "test instructions" };
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = agentChatOptions
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages);
// Assert
Assert.NotNull(capturedChatOptions);
Assert.Equal(100, capturedChatOptions.MaxOutputTokens);
Assert.Equal(0.7f, capturedChatOptions.Temperature);
Assert.Equal("test instructions", capturedChatOptions.Instructions);
}
[Fact]
public async Task ChatOptionsMergingUsesAgentOptionsConstructorWhenRequestHasNoneAsync()
{
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages);
// Assert
Assert.NotNull(capturedChatOptions);
Assert.Equal("test instructions", capturedChatOptions.Instructions);
}
/// <summary>
/// Verify that ChatOptions merging works when request has ChatOptions but agent doesn't.
/// </summary>
[Fact]
public async Task ChatOptionsMergingUsesRequestOptionsWhenAgentHasNoneAsync()
{
// Arrange
var requestChatOptions = new ChatOptions { MaxOutputTokens = 200, Temperature = 0.3f, Instructions = "test instructions" };
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions));
// Assert
Assert.NotNull(capturedChatOptions);
Assert.Equivalent(requestChatOptions, capturedChatOptions); // Should be the same instance since no merging needed
Assert.Equal(200, capturedChatOptions.MaxOutputTokens);
Assert.Equal(0.3f, capturedChatOptions.Temperature);
Assert.Equal("test instructions", capturedChatOptions.Instructions);
}
/// <summary>
/// Verify that <see cref="ChatOptions"/> merging prioritizes <see cref="AgentRunOptions"/> over request <see cref="ChatOptions"/> and that in turn over agent level <see cref="ChatOptions"/>.
/// </summary>
[Fact]
public async Task ChatOptionsMergingPrioritizesRequestOptionsOverAgentOptionsAsync()
{
// Arrange
var agentChatOptions = new ChatOptions
{
Instructions = "test instructions",
MaxOutputTokens = 100,
Temperature = 0.7f,
TopP = 0.9f,
ModelId = "agent-model",
AdditionalProperties = new AdditionalPropertiesDictionary { ["key1"] = "agent-value", ["key2"] = "agent-value", ["key3"] = "agent-value" }
};
var requestChatOptions = new ChatOptions
{
// TopP and ModelId not set, should use agent values
MaxOutputTokens = 200,
Temperature = 0.3f,
AdditionalProperties = new AdditionalPropertiesDictionary { ["key2"] = "request-value", ["key3"] = "request-value" },
Instructions = "request instructions"
};
var agentRunOptionsAdditionalProperties = new AdditionalPropertiesDictionary { ["key3"] = "runoptions-value" };
var expectedChatOptionsMerge = new ChatOptions
{
MaxOutputTokens = 200, // Request value takes priority
Temperature = 0.3f, // Request value takes priority
// Check that each level of precedence is respected in AdditionalProperties
AdditionalProperties = new AdditionalPropertiesDictionary { ["key1"] = "agent-value", ["key2"] = "request-value", ["key3"] = "runoptions-value" },
TopP = 0.9f, // Agent value used when request doesn't specify
ModelId = "agent-model", // Agent value used when request doesn't specify
Instructions = "test instructions\nrequest instructions" // Request is in addition to agent instructions
};
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = agentChatOptions
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions) { AdditionalProperties = agentRunOptionsAdditionalProperties });
// Assert
Assert.NotNull(capturedChatOptions);
Assert.Equivalent(expectedChatOptionsMerge, capturedChatOptions); // Should be the same instance (modified in place)
Assert.Equal(200, capturedChatOptions.MaxOutputTokens); // Request value takes priority
Assert.Equal(0.3f, capturedChatOptions.Temperature); // Request value takes priority
Assert.NotNull(capturedChatOptions.AdditionalProperties);
Assert.Equal("agent-value", capturedChatOptions.AdditionalProperties["key1"]); // Agent value used when request doesn't specify
Assert.Equal("request-value", capturedChatOptions.AdditionalProperties["key2"]); // Request ChatOptions value takes priority over agent ChatOptions value
Assert.Equal("runoptions-value", capturedChatOptions.AdditionalProperties["key3"]); // Run options value takes priority over request and agent ChatOptions values
Assert.Equal(0.9f, capturedChatOptions.TopP); // Agent value used when request doesn't specify
Assert.Equal("agent-model", capturedChatOptions.ModelId); // Agent value used when request doesn't specify
}
/// <summary>
/// Verify that when both agent and request have no ChatOptions, the inner client
/// receives null options.
/// </summary>
[Fact]
public async Task ChatOptionsMergingReturnsNullChatOptionsWhenBothAgentAndRequestHaveNoneAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages);
// Assert
Assert.Null(capturedChatOptions);
}
/// <summary>
/// Verify that ChatOptions merging concatenates Tools from agent and request.
/// </summary>
[Fact]
public async Task ChatOptionsMergingConcatenatesToolsFromAgentAndRequestAsync()
{
// Arrange
var agentTool = AIFunctionFactory.Create(() => "agent tool");
var requestTool = AIFunctionFactory.Create(() => "request tool");
var agentChatOptions = new ChatOptions
{
Instructions = "test instructions",
Tools = [agentTool]
};
var requestChatOptions = new ChatOptions
{
Tools = [requestTool]
};
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = agentChatOptions
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions));
// Assert
Assert.NotNull(capturedChatOptions);
Assert.NotNull(capturedChatOptions.Tools);
Assert.Equal(2, capturedChatOptions.Tools.Count);
// Request tools should come first, then agent tools
Assert.Contains(requestTool, capturedChatOptions.Tools);
Assert.Contains(agentTool, capturedChatOptions.Tools);
}
/// <summary>
/// Verify that ChatOptions merging uses agent Tools when request has no Tools.
/// </summary>
[Fact]
public async Task ChatOptionsMergingUsesAgentToolsWhenRequestHasNoToolsAsync()
{
// Arrange
var agentTool = AIFunctionFactory.Create(() => "agent tool");
var agentChatOptions = new ChatOptions
{
Instructions = "test instructions",
Tools = [agentTool]
};
var requestChatOptions = new ChatOptions
{
// No Tools specified
MaxOutputTokens = 100
};
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = agentChatOptions
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions));
// Assert
Assert.NotNull(capturedChatOptions);
Assert.NotNull(capturedChatOptions.Tools);
Assert.Single(capturedChatOptions.Tools);
Assert.Contains(agentTool, capturedChatOptions.Tools); // Should contain the agent's tool
}
/// <summary>
/// Verify that ChatOptions merging uses RawRepresentationFactory from request first, with fallback to agent.
/// </summary>
[Theory]
[InlineData("MockAgentSetting", "MockRequestSetting", "MockRequestSetting")]
[InlineData("MockAgentSetting", null, "MockAgentSetting")]
[InlineData(null, "MockRequestSetting", "MockRequestSetting")]
public async Task ChatOptionsMergingUsesRawRepresentationFactoryWithFallbackAsync(string? agentSetting, string? requestSetting, string expectedSetting)
{
// Arrange
var agentChatOptions = new ChatOptions
{
Instructions = "test instructions",
RawRepresentationFactory = _ => agentSetting
};
var requestChatOptions = new ChatOptions
{
RawRepresentationFactory = _ => requestSetting
};
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = agentChatOptions
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions));
// Assert
Assert.NotNull(capturedChatOptions);
Assert.NotNull(capturedChatOptions.RawRepresentationFactory);
Assert.Equal(expectedSetting, capturedChatOptions.RawRepresentationFactory(null!));
}
/// <summary>
/// Verify that <see cref="ChatOptions.Reasoning"/> from the request takes priority over the agent's.
/// </summary>
[Fact]
public async Task ChatOptionsMergingUsesRequestReasoningOverAgentReasoningAsync()
{
// Arrange
var agentReasoning = new ReasoningOptions { Effort = ReasoningEffort.Low, Output = ReasoningOutput.Full };
var requestReasoning = new ReasoningOptions { Effort = ReasoningEffort.High, Output = ReasoningOutput.Full };
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new ChatOptions { Reasoning = agentReasoning }
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(new ChatOptions { Reasoning = requestReasoning }));
// Assert
Assert.NotNull(capturedChatOptions);
Assert.NotNull(capturedChatOptions.Reasoning);
Assert.Equal(requestReasoning.Effort, capturedChatOptions.Reasoning.Effort);
Assert.Equal(requestReasoning.Output, capturedChatOptions.Reasoning.Output);
}
/// <summary>
/// Verify that <see cref="ChatOptions.Reasoning"/> falls back to the agent's when the request has none.
/// </summary>
[Fact]
public async Task ChatOptionsMergingFallsBackToAgentReasoningWhenRequestHasNoneAsync()
{
// Arrange
var agentReasoning = new ReasoningOptions { Effort = ReasoningEffort.Low, Output = ReasoningOutput.Full };
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new ChatOptions { Reasoning = agentReasoning }
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(new ChatOptions()));
// Assert
Assert.NotNull(capturedChatOptions);
Assert.NotNull(capturedChatOptions.Reasoning);
Assert.Equal(agentReasoning.Effort, capturedChatOptions.Reasoning.Effort);
Assert.Equal(agentReasoning.Output, capturedChatOptions.Reasoning.Output);
}
/// <summary>
/// Verify that <see cref="ChatOptions.Reasoning"/> from the request is used when the agent has none.
/// </summary>
[Fact]
public async Task ChatOptionsMergingUsesRequestReasoningWhenAgentHasNoneAsync()
{
// Arrange
var requestReasoning = new ReasoningOptions { Effort = ReasoningEffort.High, Output = ReasoningOutput.Full };
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new ChatOptions()
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(new ChatOptions { Reasoning = requestReasoning }));
// Assert
Assert.NotNull(capturedChatOptions);
Assert.NotNull(capturedChatOptions.Reasoning);
Assert.Equal(requestReasoning.Effort, capturedChatOptions.Reasoning.Effort);
Assert.Equal(requestReasoning.Output, capturedChatOptions.Reasoning.Output);
}
/// <summary>
/// Verify that ChatOptions merging handles all scalar properties correctly.
/// </summary>
[Fact]
public async Task ChatOptionsMergingHandlesAllScalarPropertiesCorrectlyAsync()
{
// Arrange
var agentChatOptions = new ChatOptions
{
MaxOutputTokens = 100,
Temperature = 0.7f,
TopP = 0.9f,
TopK = 50,
PresencePenalty = 0.1f,
FrequencyPenalty = 0.2f,
Instructions = "agent instructions",
ModelId = "agent-model",
Seed = 12345,
ConversationId = "agent-conversation",
AllowMultipleToolCalls = true,
StopSequences = ["agent-stop"]
};
var requestChatOptions = new ChatOptions
{
MaxOutputTokens = 200,
Temperature = 0.3f,
Instructions = "request instructions",
// Other properties not set, should use agent values
StopSequences = ["request-stop"]
};
var expectedChatOptionsMerge = new ChatOptions
{
MaxOutputTokens = 200,
Temperature = 0.3f,
// Agent value used when request doesn't specify
TopP = 0.9f,
TopK = 50,
PresencePenalty = 0.1f,
FrequencyPenalty = 0.2f,
Instructions = "agent instructions\nrequest instructions",
ModelId = "agent-model",
Seed = 12345,
ConversationId = "agent-conversation",
AllowMultipleToolCalls = true,
// Merged StopSequences
StopSequences = ["request-stop", "agent-stop"]
};
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = agentChatOptions
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions));
// Assert
Assert.NotNull(capturedChatOptions);
Assert.Equivalent(expectedChatOptionsMerge, capturedChatOptions); // Should be the equivalent instance (modified in place)
// Request values should take priority
Assert.Equal(200, capturedChatOptions.MaxOutputTokens);
Assert.Equal(0.3f, capturedChatOptions.Temperature);
// Merge StopSequences
Assert.Equal(["request-stop", "agent-stop"], capturedChatOptions.StopSequences);
// Agent values should be used when request doesn't specify
Assert.Equal(0.9f, capturedChatOptions.TopP);
Assert.Equal(50, capturedChatOptions.TopK);
Assert.Equal(0.1f, capturedChatOptions.PresencePenalty);
Assert.Equal(0.2f, capturedChatOptions.FrequencyPenalty);
Assert.Equal("agent-model", capturedChatOptions.ModelId);
Assert.Equal(12345, capturedChatOptions.Seed);
Assert.Equal("agent-conversation", capturedChatOptions.ConversationId);
Assert.Equal(true, capturedChatOptions.AllowMultipleToolCalls);
}
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests for the ChatClientAgent.CreateSessionAsync methods.
/// </summary>
public class ChatClientAgent_CreateSessionTests
{
[Fact]
public async Task CreateSession_UsesConversationId_FromTypedOverloadAsync()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
const string TestConversationId = "test_conversation_id";
var agent = new ChatClientAgent(mockChatClient.Object);
// Act
var session = await agent.CreateSessionAsync(TestConversationId);
// Assert
Assert.IsType<ChatClientAgentSession>(session);
var typedSession = (ChatClientAgentSession)session;
Assert.Equal(TestConversationId, typedSession.ConversationId);
}
}
@@ -0,0 +1,456 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Tests for <see cref="ChatClientAgent"/> run methods with <see cref="ChatClientAgentRunOptions"/>.
/// </summary>
public sealed partial class ChatClientAgent_RunWithCustomOptionsTests
{
#region RunAsync Tests
[Fact]
public async Task RunAsync_WithSessionAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentSession session = await agent.CreateSessionAsync();
ChatClientAgentRunOptions options = new();
// Act
AgentResponse result = await agent.RunAsync(session, options);
// Assert
Assert.NotNull(result);
Assert.Single(result.Messages);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsync_WithStringMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentSession session = await agent.CreateSessionAsync();
ChatClientAgentRunOptions options = new();
// Act
AgentResponse result = await agent.RunAsync("Test message", session, options);
// Assert
Assert.NotNull(result);
Assert.Single(result.Messages);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Any(m => m.Text == "Test message")),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsync_WithChatMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentSession session = await agent.CreateSessionAsync();
ChatMessage message = new(ChatRole.User, "Test message");
ChatClientAgentRunOptions options = new();
// Act
AgentResponse result = await agent.RunAsync(message, session, options);
// Assert
Assert.NotNull(result);
Assert.Single(result.Messages);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Contains(message)),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsync_WithMessagesCollectionAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentSession session = await agent.CreateSessionAsync();
IEnumerable<ChatMessage> messages = [new(ChatRole.User, "Message 1"), new(ChatRole.User, "Message 2")];
ChatClientAgentRunOptions options = new();
// Act
AgentResponse result = await agent.RunAsync(messages, session, options);
// Assert
Assert.NotNull(result);
Assert.Single(result.Messages);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsync_WithChatOptionsInRunOptions_UsesChatOptionsAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentRunOptions options = new(new ChatOptions { Temperature = 0.5f });
// Act
AgentResponse result = await agent.RunAsync("Test", null, options);
// Assert
Assert.NotNull(result);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.Is<ChatOptions>(opts => opts.Temperature == 0.5f),
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region RunStreamingAsync Tests
[Fact]
public async Task RunStreamingAsync_WithSessionAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
ChatClientAgent agent = new(mockChatClient.Object);
AgentSession session = await agent.CreateSessionAsync();
ChatClientAgentRunOptions options = new();
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(session, options))
{
updates.Add(update);
}
// Assert
Assert.NotEmpty(updates);
mockChatClient.Verify(
x => x.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_WithStringMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
ChatClientAgent agent = new(mockChatClient.Object);
AgentSession session = await agent.CreateSessionAsync();
ChatClientAgentRunOptions options = new();
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync("Test message", session, options))
{
updates.Add(update);
}
// Assert
Assert.NotEmpty(updates);
mockChatClient.Verify(
x => x.GetStreamingResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Any(m => m.Text == "Test message")),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_WithChatMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
ChatClientAgent agent = new(mockChatClient.Object);
AgentSession session = await agent.CreateSessionAsync();
ChatMessage message = new(ChatRole.User, "Test message");
ChatClientAgentRunOptions options = new();
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(message, session, options))
{
updates.Add(update);
}
// Assert
Assert.NotEmpty(updates);
mockChatClient.Verify(
x => x.GetStreamingResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Contains(message)),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_WithMessagesCollectionAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
ChatClientAgent agent = new(mockChatClient.Object);
AgentSession session = await agent.CreateSessionAsync();
IEnumerable<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Message 1"), new ChatMessage(ChatRole.User, "Message 2")];
ChatClientAgentRunOptions options = new();
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(messages, session, options))
{
updates.Add(update);
}
// Assert
Assert.NotEmpty(updates);
mockChatClient.Verify(
x => x.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region Helper Methods
private static async IAsyncEnumerable<ChatResponseUpdate> GetAsyncUpdatesAsync()
{
yield return new ChatResponseUpdate { Contents = new[] { new TextContent("Hello") } };
yield return new ChatResponseUpdate { Contents = new[] { new TextContent(" World") } };
await Task.CompletedTask;
}
#endregion
#region RunAsync{T} Tests
[Fact]
public async Task RunAsyncOfT_WithSessionAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentSession session = await agent.CreateSessionAsync();
ChatClientAgentRunOptions options = new();
// Act
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>(session, JsonContext_WithCustomRunOptions.Default.Options, options);
// Assert
Assert.NotNull(agentResponse);
Assert.Single(agentResponse.Messages);
Assert.Equal("Tigger", agentResponse.Result.FullName);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsyncOfT_WithStringMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentSession session = await agent.CreateSessionAsync();
ChatClientAgentRunOptions options = new();
// Act
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>("Test message", session, JsonContext_WithCustomRunOptions.Default.Options, options);
// Assert
Assert.NotNull(agentResponse);
Assert.Single(agentResponse.Messages);
Assert.Equal("Tigger", agentResponse.Result.FullName);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Any(m => m.Text == "Test message")),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsyncOfT_WithChatMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentSession session = await agent.CreateSessionAsync();
ChatMessage message = new(ChatRole.User, "Test message");
ChatClientAgentRunOptions options = new();
// Act
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>(message, session, JsonContext_WithCustomRunOptions.Default.Options, options);
// Assert
Assert.NotNull(agentResponse);
Assert.Single(agentResponse.Messages);
Assert.Equal("Tigger", agentResponse.Result.FullName);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Contains(message)),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsyncOfT_WithMessagesCollectionAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentSession session = await agent.CreateSessionAsync();
IEnumerable<ChatMessage> messages = [new(ChatRole.User, "Message 1"), new(ChatRole.User, "Message 2")];
ChatClientAgentRunOptions options = new();
// Act
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>(messages, session, JsonContext_WithCustomRunOptions.Default.Options, options);
// Assert
Assert.NotNull(agentResponse);
Assert.Single(agentResponse.Messages);
Assert.Equal("Tigger", agentResponse.Result.FullName);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
private sealed class Animal
{
public int Id { get; set; }
public string? FullName { get; set; }
public Species Species { get; set; }
}
private enum Species
{
Bear,
Tiger,
Walrus,
}
[JsonSourceGenerationOptions(UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Animal))]
private sealed partial class JsonContext_WithCustomRunOptions : JsonSerializerContext;
}
@@ -0,0 +1,212 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
public partial class ChatClientAgent_StructuredOutput_WithFormatResponseTests
{
[Fact]
public async Task RunAsync_ResponseFormatProvidedAtAgentInitialization_IsPropagatedToChatClientAsync()
{
// Arrange
ChatResponseFormat? capturedResponseFormat = null;
Mock<IChatClient> mockService = new();
mockService.Setup(s => s
.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test"))
{
ResponseId = "test",
});
ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions
{
ChatOptions = new ChatOptions()
{
ResponseFormat = responseFormat
}
});
// Act
await agent.RunAsync(messages: [new(ChatRole.User, "Hello")]);
// Assert
Assert.NotNull(capturedResponseFormat);
Assert.Same(responseFormat, capturedResponseFormat);
}
[Fact]
public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_IsPropagatedToChatClientAsync()
{
// Arrange
ChatResponseFormat? capturedResponseFormat = null;
Mock<IChatClient> mockService = new();
mockService.Setup(s => s
.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test"))
{
ResponseId = "test",
});
ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
ChatClientAgent agent = new(mockService.Object);
ChatClientAgentRunOptions runOptions = new()
{
ResponseFormat = responseFormat
};
// Act
await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions);
// Assert
Assert.NotNull(capturedResponseFormat);
Assert.Same(responseFormat, capturedResponseFormat);
}
[Fact]
public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_OverridesOneProvidedAtAgentInitializationAsync()
{
// Arrange
ChatResponseFormat? capturedResponseFormat = null;
Mock<IChatClient> mockService = new();
mockService.Setup(s => s
.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test"))
{
ResponseId = "test",
});
ChatResponseFormatJson initializationResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
ChatResponseFormatJson invocationResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions
{
ChatOptions = new ChatOptions()
{
ResponseFormat = initializationResponseFormat
},
});
ChatClientAgentRunOptions runOptions = new()
{
ResponseFormat = invocationResponseFormat
};
// Act
await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions);
// Assert
Assert.NotNull(capturedResponseFormat);
Assert.Same(invocationResponseFormat, capturedResponseFormat);
Assert.NotSame(initializationResponseFormat, capturedResponseFormat);
}
[Fact]
public async Task RunAsync_ResponseFormatProvidedAtAgentRunOptions_OverridesOneProvidedViaChatOptionsAsync()
{
// Arrange
ChatResponseFormat? capturedResponseFormat = null;
Mock<IChatClient> mockService = new();
mockService.Setup(s => s
.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test"))
{
ResponseId = "test",
});
ChatResponseFormatJson chatOptionsResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
ChatResponseFormatJson runOptionsResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
ChatClientAgent agent = new(mockService.Object);
ChatClientAgentRunOptions runOptions = new()
{
ChatOptions = new ChatOptions
{
ResponseFormat = chatOptionsResponseFormat
},
ResponseFormat = runOptionsResponseFormat
};
// Act
await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions);
// Assert
Assert.NotNull(capturedResponseFormat);
Assert.Same(runOptionsResponseFormat, capturedResponseFormat);
Assert.NotSame(chatOptionsResponseFormat, capturedResponseFormat);
}
[Fact]
public async Task RunAsync_StructuredOutputResponse_IsAvailableAsTextOnAgentResponseAsync()
{
// Arrange
Animal expectedAnimal = new() { FullName = "Wally the Walrus", Id = 1, Species = Species.Walrus };
Mock<IChatClient> mockService = new();
mockService.Setup(s => s
.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedAnimal, JsonContext4.Default.Animal)))
{
ResponseId = "test",
});
ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions
{
ChatOptions = new ChatOptions()
{
ResponseFormat = responseFormat
},
});
// Act
AgentResponse agentResponse = await agent.RunAsync(messages: [new(ChatRole.User, "Hello")]);
// Assert
Assert.NotNull(agentResponse?.Text);
Animal? deserialised = JsonSerializer.Deserialize(agentResponse.Text, JsonContext4.Default.Animal);
Assert.NotNull(deserialised);
Assert.Equal(expectedAnimal.Id, deserialised.Id);
Assert.Equal(expectedAnimal.FullName, deserialised.FullName);
Assert.Equal(expectedAnimal.Species, deserialised.Species);
}
[JsonSerializable(typeof(Animal))]
private sealed partial class JsonContext4 : JsonSerializerContext;
}
@@ -0,0 +1,56 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
public partial class ChatClientAgent_StructuredOutput_WithRunAsyncTests
{
[Fact]
public async Task RunAsync_WithGenericType_SetsJsonSchemaResponseFormatAndDeserializesResultAsync()
{
// Arrange
ChatResponseFormat? capturedResponseFormat = null;
ChatResponseFormatJson expectedResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext3.Default.Options);
Animal expectedSO = new() { Id = 1, FullName = "Tigger", Species = Species.Tiger };
Mock<IChatClient> mockService = new();
mockService.Setup(s => s
.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedSO, JsonContext3.Default.Animal)))
{
ResponseId = "test",
});
ChatClientAgent agent = new(mockService.Object);
// Act
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>(
messages: [new(ChatRole.User, "Hello")],
serializerOptions: JsonContext3.Default.Options);
// Assert
Assert.NotNull(capturedResponseFormat);
Assert.Equal(expectedResponseFormat.Schema?.GetRawText(), ((ChatResponseFormatJson)capturedResponseFormat).Schema?.GetRawText());
Animal animal = agentResponse.Result;
Assert.NotNull(animal);
Assert.Equal(expectedSO.Id, animal.Id);
Assert.Equal(expectedSO.FullName, animal.FullName);
Assert.Equal(expectedSO.Species, animal.Species);
}
[JsonSourceGenerationOptions(UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Animal))]
private sealed partial class JsonContext3 : JsonSerializerContext;
}
@@ -0,0 +1,215 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests for the <see cref="ChatClientBuilderExtensions"/> class.
/// </summary>
public sealed class ChatClientBuilderExtensionsTests
{
[Fact]
public void BuildAIAgent_WithBasicParameters_CreatesAgent()
{
// Arrange
var innerChatClientMock = new Mock<IChatClient>();
var builder = new ChatClientBuilder(innerChatClientMock.Object);
// Act
var agent = builder.BuildAIAgent(
instructions: "Test instructions",
name: "TestAgent",
description: "Test description"
);
// Assert
Assert.NotNull(agent);
Assert.Equal("TestAgent", agent.Name);
Assert.Equal("Test description", agent.Description);
Assert.Equal("Test instructions", agent.Instructions);
}
[Fact]
public void BuildAIAgent_WithTools_SetsToolsInOptions()
{
// Arrange
var innerChatClientMock = new Mock<IChatClient>();
var builder = new ChatClientBuilder(innerChatClientMock.Object);
var tools = new List<AITool> { new Mock<AITool>().Object };
// Act
var agent = builder.BuildAIAgent(tools: tools);
// Assert
Assert.NotNull(agent);
Assert.NotNull(agent.ChatOptions);
Assert.Equal(tools, agent.ChatOptions.Tools);
}
[Fact]
public void BuildAIAgent_WithAllParameters_CreatesAgentCorrectly()
{
// Arrange
var innerChatClientMock = new Mock<IChatClient>();
var builder = new ChatClientBuilder(innerChatClientMock.Object);
var tools = new List<AITool> { new Mock<AITool>().Object };
var loggerFactoryMock = new Mock<ILoggerFactory>();
var serviceProviderMock = new Mock<IServiceProvider>();
// Act
var agent = builder.BuildAIAgent(
instructions: "Complex instructions",
name: "ComplexAgent",
description: "Complex description",
tools: tools,
loggerFactory: loggerFactoryMock.Object,
services: serviceProviderMock.Object
);
// Assert
Assert.NotNull(agent);
Assert.Equal("ComplexAgent", agent.Name);
Assert.Equal("Complex description", agent.Description);
Assert.Equal("Complex instructions", agent.Instructions);
Assert.NotNull(agent.ChatOptions);
Assert.Equal(tools, agent.ChatOptions.Tools);
}
[Fact]
public void BuildAIAgent_WithOptions_CreatesAgentWithOptions()
{
// Arrange
var innerChatClientMock = new Mock<IChatClient>();
var builder = new ChatClientBuilder(innerChatClientMock.Object);
var options = new ChatClientAgentOptions
{
Name = "AgentWithOptions",
Description = "Desc",
ChatOptions = new() { Instructions = "Instr" },
UseProvidedChatClientAsIs = true
};
// Act
var agent = builder.BuildAIAgent(options);
// Assert
Assert.NotNull(agent);
Assert.Equal("AgentWithOptions", agent.Name);
Assert.Equal("Desc", agent.Description);
Assert.Equal("Instr", agent.Instructions);
}
[Fact]
public void BuildAIAgent_WithOptionsAndServices_CreatesAgentCorrectly()
{
// Arrange
var innerChatClientMock = new Mock<IChatClient>();
var builder = new ChatClientBuilder(innerChatClientMock.Object);
var loggerFactoryMock = new Mock<ILoggerFactory>();
var serviceProviderMock = new Mock<IServiceProvider>();
var options = new ChatClientAgentOptions
{
Name = "ServiceAgent",
ChatOptions = new() { Instructions = "Service instructions" }
};
// Act
var agent = builder.BuildAIAgent(
options: options,
loggerFactory: loggerFactoryMock.Object,
services: serviceProviderMock.Object
);
// Assert
Assert.NotNull(agent);
Assert.Equal("ServiceAgent", agent.Name);
Assert.Equal("Service instructions", agent.Instructions);
}
[Fact]
public void BuildAIAgent_WithNullBuilder_Throws()
{
// Arrange
ChatClientBuilder builder = null!;
// Act & Assert
Assert.Throws<ArgumentNullException>(() => builder.BuildAIAgent(instructions: "instructions"));
}
[Fact]
public void BuildAIAgent_WithNullBuilderAndOptions_Throws()
{
// Arrange
ChatClientBuilder builder = null!;
// Act & Assert
Assert.Throws<ArgumentNullException>(() => builder.BuildAIAgent(options: new() { ChatOptions = new() { Instructions = "instructions" } }));
}
[Fact]
public void BuildAIAgent_WithMiddleware_BuildsCorrectPipeline()
{
// Arrange
var innerChatClientMock = new Mock<IChatClient>();
var middlewareChatClientMock = new Mock<IChatClient>();
var builder = new ChatClientBuilder(innerChatClientMock.Object);
// Add middleware that returns our mock
builder.Use((client, services) => middlewareChatClientMock.Object);
// Act
var agent = builder.BuildAIAgent(
new ChatClientAgentOptions
{
ChatOptions = new() { Instructions = "Middleware test" },
UseProvidedChatClientAsIs = true
}
);
// Assert
Assert.NotNull(agent);
Assert.Equal("Middleware test", agent.Instructions);
// When UseProvidedChatClientAsIs is true, the agent should use the middleware chat client directly
Assert.Same(middlewareChatClientMock.Object, agent.ChatClient);
}
[Fact]
public void BuildAIAgent_WithNullOptions_CreatesAgentWithDefaults()
{
// Arrange
var innerChatClientMock = new Mock<IChatClient>();
var builder = new ChatClientBuilder(innerChatClientMock.Object);
// Act
var agent = builder.BuildAIAgent(options: null);
// Assert
Assert.NotNull(agent);
Assert.Null(agent.Name);
Assert.Null(agent.Description);
Assert.Null(agent.Instructions);
}
[Fact]
public void BuildAIAgent_WithEmptyParameters_CreatesMinimalAgent()
{
// Arrange
var innerChatClientMock = new Mock<IChatClient>();
var builder = new ChatClientBuilder(innerChatClientMock.Object);
// Act
var agent = builder.BuildAIAgent();
// Assert
Assert.NotNull(agent);
Assert.Null(agent.Name);
Assert.Null(agent.Description);
Assert.Null(agent.Instructions);
Assert.Null(agent.ChatOptions);
}
}
@@ -0,0 +1,94 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests for the ChatClientExtensions class.
/// </summary>
public sealed class ChatClientExtensionsTests
{
[Fact]
public void CreateAIAgent_WithBasicParameters_CreatesAgent()
{
// Arrange
var chatClientMock = new Mock<IChatClient>();
// Act
var agent = chatClientMock.Object.AsAIAgent(
instructions: "Test instructions",
name: "TestAgent",
description: "Test description"
);
// Assert
Assert.NotNull(agent);
Assert.Equal("TestAgent", agent.Name);
Assert.Equal("Test description", agent.Description);
Assert.Equal("Test instructions", agent.Instructions);
}
[Fact]
public void CreateAIAgent_WithTools_SetsToolsInOptions()
{
// Arrange
var chatClientMock = new Mock<IChatClient>();
var tools = new List<AITool> { new Mock<AITool>().Object };
// Act
var agent = chatClientMock.Object.AsAIAgent(tools: tools);
// Assert
Assert.NotNull(agent);
Assert.NotNull(agent.ChatOptions);
Assert.Equal(tools, agent.ChatOptions.Tools);
}
[Fact]
public void CreateAIAgent_WithOptions_CreatesAgentWithOptions()
{
// Arrange
var chatClientMock = new Mock<IChatClient>();
var options = new ChatClientAgentOptions
{
Name = "AgentWithOptions",
Description = "Desc",
ChatOptions = new() { Instructions = "Instr" },
UseProvidedChatClientAsIs = true
};
// Act
var agent = chatClientMock.Object.AsAIAgent(options);
// Assert
Assert.NotNull(agent);
Assert.Equal("AgentWithOptions", agent.Name);
Assert.Equal("Desc", agent.Description);
Assert.Equal("Instr", agent.Instructions);
Assert.Same(chatClientMock.Object, agent.ChatClient);
}
[Fact]
public void CreateAIAgent_WithNullClient_Throws()
{
// Arrange
IChatClient chatClient = null!;
// Act & Assert
Assert.Throws<ArgumentNullException>(() => chatClient.AsAIAgent(instructions: "instructions"));
}
[Fact]
public void CreateAIAgent_WithNullClientAndOptions_Throws()
{
// Arrange
IChatClient chatClient = null!;
// Act & Assert
Assert.Throws<ArgumentNullException>(() => chatClient.AsAIAgent(options: new() { ChatOptions = new() { Instructions = "instructions" } }));
}
}
@@ -0,0 +1,493 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for <see cref="MessageInjectingChatClient"/>.
/// </summary>
public class MessageInjectingChatClientTests
{
/// <summary>
/// Verifies that <see cref="MessageInjectingChatClient"/> is resolvable via GetService when the decorator is active.
/// </summary>
[Fact]
public void GetService_ReturnsMessageInjectingChatClient_WhenDecoratorActive()
{
// Arrange
Mock<IChatClient> mockService = new();
ChatClientAgent agent = new(mockService.Object, options: new()
{
EnableMessageInjection = true,
});
// Act
var injector = agent.ChatClient.GetService<MessageInjectingChatClient>();
// Assert
Assert.NotNull(injector);
}
/// <summary>
/// Verifies that <see cref="MessageInjectingChatClient"/> is null when the decorator is not active.
/// </summary>
[Fact]
public void GetService_ReturnsNull_WhenDecoratorNotActive()
{
// Arrange
Mock<IChatClient> mockService = new();
ChatClientAgent agent = new(mockService.Object, options: new());
// Act
var injector = agent.ChatClient.GetService<MessageInjectingChatClient>();
// Assert
Assert.Null(injector);
}
/// <summary>
/// Verifies that messages enqueued on the session before RunAsync are included in the service call messages.
/// </summary>
[Fact]
public async Task RunAsync_IncludesInjectedMessages_WhenEnqueuedBeforeCallAsync()
{
// Arrange
List<ChatMessage> capturedMessages = [];
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
capturedMessages.AddRange(msgs))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
EnableMessageInjection = true,
});
// Create session and enqueue a message directly onto the session's StateBag queue before calling RunAsync
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
var queue = new List<ChatMessage>();
queue.Add(new ChatMessage(ChatRole.User, "injected message"));
session!.StateBag.SetValue("MessageInjectingChatClient.PendingInjectedMessages", queue);
// Act
await agent.RunAsync([new(ChatRole.User, "original")], session);
// Assert — the service should have received both the original and injected messages
Assert.Contains(capturedMessages, m => m.Text == "original");
Assert.Contains(capturedMessages, m => m.Text == "injected message");
}
/// <summary>
/// Verifies that the queue is drained after a call (messages are not re-delivered on subsequent calls).
/// </summary>
[Fact]
public async Task RunAsync_DrainsQueue_MessagesNotRedeliveredAsync()
{
// Arrange
List<ChatMessage> capturedMessages = [];
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
capturedMessages.AddRange(msgs))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
EnableMessageInjection = true,
});
// Create session and enqueue a message directly onto the session's StateBag queue
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
var queue = new List<ChatMessage>();
queue.Add(new ChatMessage(ChatRole.User, "injected once"));
session!.StateBag.SetValue("MessageInjectingChatClient.PendingInjectedMessages", queue);
// Act
await agent.RunAsync([new(ChatRole.User, "first call")], session);
// Assert — the injected message was included in the service call
Assert.Contains(capturedMessages, m => m.Text == "injected once");
// Assert — the session's queue is now empty (drained)
Assert.Empty(queue);
}
/// <summary>
/// Verifies that the internal loop fires when no actionable FunctionCallContent is returned
/// but there are pending injected messages in the queue.
/// </summary>
[Fact]
public async Task RunAsync_LoopsInternally_WhenNoActionableFCCButPendingMessagesAsync()
{
// Arrange
int serviceCallCount = 0;
Mock<IChatClient> mockService = new();
MessageInjectingChatClient? injectorRef = null;
ChatClientAgentSession? sessionRef = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
{
serviceCallCount++;
if (serviceCallCount == 1)
{
// First call — simulate that something enqueues a message (e.g., a provider or background task)
injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected during first call")]);
}
// Return a plain text response (no FunctionCallContent) to trigger the internal loop
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, $"response {serviceCallCount}")]));
});
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
EnableMessageInjection = true,
});
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
sessionRef = session;
await agent.RunAsync([new(ChatRole.User, "original")], session);
// Assert — should have made 2 service calls (internal loop triggered by the injected message)
Assert.Equal(2, serviceCallCount);
}
/// <summary>
/// Verifies that the internal loop does NOT fire when the response contains actionable
/// FunctionCallContent, even if there are pending injected messages.
/// </summary>
[Fact]
public async Task RunAsync_DoesNotLoopInternally_WhenActionableFCCPresentAsync()
{
// Arrange
int serviceCallCount = 0;
Mock<IChatClient> mockService = new();
MessageInjectingChatClient? injectorRef = null;
ChatClientAgentSession? sessionRef = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
{
serviceCallCount++;
if (serviceCallCount == 1)
{
// Enqueue a message during the first call
injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]);
// Return a response with an actionable FunctionCallContent
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>())])]));
}
// Subsequent calls return plain text (the FCC loop will call back after tool execution)
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final")]));
});
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
EnableMessageInjection = true,
}, services: new ServiceCollection().BuildServiceProvider());
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
sessionRef = session;
await agent.RunAsync([new(ChatRole.User, "original")], session);
// Assert — The first service call returned actionable FCC, so no internal injected-message loop
// occurred there. The FCC loop invokes the tool and calls the service again (second call).
// The injected message should be picked up by the second service call (drained at start of
// GetResponseAsync), but no extra internal loop should fire. Exactly 2 service calls expected.
Assert.Equal(2, serviceCallCount);
}
/// <summary>
/// Verifies that the internal loop fires when the response contains only InformationalOnly
/// FunctionCallContent (which are not actionable) and there are pending injected messages.
/// </summary>
[Fact]
public async Task RunAsync_LoopsInternally_WhenOnlyInformationalOnlyFCCAndPendingMessagesAsync()
{
// Arrange
int serviceCallCount = 0;
Mock<IChatClient> mockService = new();
MessageInjectingChatClient? injectorRef = null;
ChatClientAgentSession? sessionRef = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
{
serviceCallCount++;
if (serviceCallCount == 1)
{
// Enqueue a message during the first call
injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]);
// Return a response with InformationalOnly FCC (not actionable)
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>()) { InformationalOnly = true }])]));
}
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final")]));
});
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
EnableMessageInjection = true,
});
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
sessionRef = session;
await agent.RunAsync([new(ChatRole.User, "original")], session);
// Assert — InformationalOnly FCC is NOT actionable, so internal loop should trigger
Assert.Equal(2, serviceCallCount);
}
/// <summary>
/// Verifies that when the inner client returns a ConversationId on the first call, the
/// MessageInjectingChatClient propagates it to options on subsequent loop iterations.
/// </summary>
[Fact]
public async Task RunAsync_PropagatesConversationId_AcrossInternalLoopIterationsAsync()
{
// Arrange
int serviceCallCount = 0;
List<string?> capturedConversationIds = [];
MessageInjectingChatClient? injectorRef = null;
ChatClientAgentSession? sessionRef = null;
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> _, ChatOptions? opts, CancellationToken _) =>
{
serviceCallCount++;
capturedConversationIds.Add(opts?.ConversationId);
if (serviceCallCount == 1)
{
// First call: inject a message and return a ConversationId
injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]);
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "first response")])
{
ConversationId = "conv-123",
});
}
// Second call (from loop): should have the propagated ConversationId
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "second response")]));
});
ChatClientAgent agent = new(mockService.Object, options: new()
{
EnableMessageInjection = true,
}, services: new ServiceCollection().BuildServiceProvider());
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
sessionRef = session;
await agent.RunAsync([new(ChatRole.User, "hello")], session);
// Assert — The second call should have received the ConversationId propagated from the first response
Assert.Equal(2, serviceCallCount);
Assert.Null(capturedConversationIds[0]); // First call: no ConversationId yet
Assert.Equal("conv-123", capturedConversationIds[1]); // Second call: propagated from first response
}
/// <summary>
/// Verifies that a session with pending injected messages can be serialized and deserialized,
/// and that the deserialized session correctly delivers the injected messages on the next run.
/// </summary>
[Fact]
public async Task RunAsync_DeliversInjectedMessages_AfterSessionSerializationRoundTripAsync()
{
// Arrange
List<ChatMessage> capturedMessagesFirstRun = [];
List<ChatMessage> capturedMessagesSecondRun = [];
int runCount = 0;
Mock<IChatClient> mockService = new();
MessageInjectingChatClient? injectorRef = null;
ChatClientAgentSession? sessionRef = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
{
if (runCount == 1)
{
capturedMessagesFirstRun.AddRange(msgs);
// Inject a message during the first run — this will remain pending (not drained)
// because we return an actionable FCC that causes the parent loop to take over.
injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected before serialization")]);
// Return actionable FCC so the injection loop does NOT drain the message
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>())])]));
}
// Second run (after deserialization) — capture what messages come through
capturedMessagesSecondRun.AddRange(msgs);
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")]));
});
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
EnableMessageInjection = true,
}, services: new ServiceCollection().BuildServiceProvider());
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
// Act — First run: inject a message that stays pending
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
sessionRef = session;
runCount = 1;
await agent.RunAsync([new(ChatRole.User, "first run message")], session);
// Serialize the session and deserialize into a new instance
var serialized = await agent.SerializeSessionAsync(session!);
var deserializedSession = await agent.DeserializeSessionAsync(serialized) as ChatClientAgentSession;
// Second run on the deserialized session — the injected message should be delivered
runCount = 2;
sessionRef = deserializedSession;
await agent.RunAsync([new(ChatRole.User, "second run message")], deserializedSession);
// Assert — the second run should include the injected message from before serialization
Assert.Contains(capturedMessagesSecondRun, m => m.Text == "injected before serialization");
Assert.Contains(capturedMessagesSecondRun, m => m.Text == "second run message");
}
}
@@ -0,0 +1,518 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="ChatMessageContentEquality"/> extension methods.
/// </summary>
public class ChatMessageContentEqualityTests
{
#region Null and reference handling
[Fact]
public void BothNullReturnsTrue()
{
ChatMessage? a = null;
ChatMessage? b = null;
Assert.True(a.ContentEquals(b));
}
[Fact]
public void LeftNullReturnsFalse()
{
ChatMessage? a = null;
ChatMessage b = new(ChatRole.User, "Hello");
Assert.False(a.ContentEquals(b));
}
[Fact]
public void RightNullReturnsFalse()
{
ChatMessage a = new(ChatRole.User, "Hello");
ChatMessage? b = null;
Assert.False(a.ContentEquals(b));
}
[Fact]
public void SameReferenceReturnsTrue()
{
ChatMessage a = new(ChatRole.User, "Hello");
Assert.True(a.ContentEquals(a));
}
#endregion
#region MessageId shortcut
[Fact]
public void MatchingMessageIdReturnsTrue()
{
ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
ChatMessage b = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
Assert.True(a.ContentEquals(b));
}
[Fact]
public void MatchingMessageIdSufficientDespiteDifferentContent()
{
ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
ChatMessage b = new(ChatRole.Assistant, "Goodbye") { MessageId = "msg-1" };
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentMessageIdReturnsFalse()
{
ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
ChatMessage b = new(ChatRole.User, "Hello") { MessageId = "msg-2" };
Assert.False(a.ContentEquals(b));
}
[Fact]
public void OnlyLeftHasMessageIdFallsThroughToContentComparison()
{
ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
ChatMessage b = new(ChatRole.User, "Hello");
Assert.True(a.ContentEquals(b));
}
[Fact]
public void OnlyRightHasMessageIdFallsThroughToContentComparison()
{
ChatMessage a = new(ChatRole.User, "Hello");
ChatMessage b = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
Assert.True(a.ContentEquals(b));
}
#endregion
#region Role and AuthorName
[Fact]
public void DifferentRoleReturnsFalse()
{
ChatMessage a = new(ChatRole.User, "Hello");
ChatMessage b = new(ChatRole.Assistant, "Hello");
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentAuthorNameReturnsFalse()
{
ChatMessage a = new(ChatRole.User, "Hello") { AuthorName = "Alice" };
ChatMessage b = new(ChatRole.User, "Hello") { AuthorName = "Bob" };
Assert.False(a.ContentEquals(b));
}
[Fact]
public void BothNullAuthorNamesAreEqual()
{
ChatMessage a = new(ChatRole.User, "Hello");
ChatMessage b = new(ChatRole.User, "Hello");
Assert.True(a.ContentEquals(b));
}
#endregion
#region TextContent
[Fact]
public void EqualTextContentReturnsTrue()
{
ChatMessage a = new(ChatRole.User, "Hello world");
ChatMessage b = new(ChatRole.User, "Hello world");
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentTextContentReturnsFalse()
{
ChatMessage a = new(ChatRole.User, "Hello");
ChatMessage b = new(ChatRole.User, "Goodbye");
Assert.False(a.ContentEquals(b));
}
[Fact]
public void TextContentIsCaseSensitive()
{
ChatMessage a = new(ChatRole.User, "Hello");
ChatMessage b = new(ChatRole.User, "hello");
Assert.False(a.ContentEquals(b));
}
#endregion
#region TextReasoningContent
[Fact]
public void EqualTextReasoningContentReturnsTrue()
{
ChatMessage a = new(ChatRole.Assistant, [new TextReasoningContent("thinking...") { ProtectedData = "opaque" }]);
ChatMessage b = new(ChatRole.Assistant, [new TextReasoningContent("thinking...") { ProtectedData = "opaque" }]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentReasoningTextReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new TextReasoningContent("alpha")]);
ChatMessage b = new(ChatRole.Assistant, [new TextReasoningContent("beta")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentProtectedDataReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new TextReasoningContent("same") { ProtectedData = "x" }]);
ChatMessage b = new(ChatRole.Assistant, [new TextReasoningContent("same") { ProtectedData = "y" }]);
Assert.False(a.ContentEquals(b));
}
#endregion
#region DataContent
[Fact]
public void EqualDataContentReturnsTrue()
{
byte[] data = Encoding.UTF8.GetBytes("payload");
ChatMessage a = new(ChatRole.User, [new DataContent(data, "application/octet-stream") { Name = "file.bin" }]);
ChatMessage b = new(ChatRole.User, [new DataContent(data, "application/octet-stream") { Name = "file.bin" }]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentDataBytesReturnsFalse()
{
ChatMessage a = new(ChatRole.User, [new DataContent(Encoding.UTF8.GetBytes("aaa"), "text/plain")]);
ChatMessage b = new(ChatRole.User, [new DataContent(Encoding.UTF8.GetBytes("bbb"), "text/plain")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentMediaTypeReturnsFalse()
{
byte[] data = [1, 2, 3];
ChatMessage a = new(ChatRole.User, [new DataContent(data, "image/png")]);
ChatMessage b = new(ChatRole.User, [new DataContent(data, "image/jpeg")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentDataContentNameReturnsFalse()
{
byte[] data = [1, 2, 3];
ChatMessage a = new(ChatRole.User, [new DataContent(data, "image/png") { Name = "a.png" }]);
ChatMessage b = new(ChatRole.User, [new DataContent(data, "image/png") { Name = "b.png" }]);
Assert.False(a.ContentEquals(b));
}
#endregion
#region UriContent
[Fact]
public void EqualUriContentReturnsTrue()
{
ChatMessage a = new(ChatRole.User, [new UriContent(new Uri("https://example.com/image.png"), "image/png")]);
ChatMessage b = new(ChatRole.User, [new UriContent(new Uri("https://example.com/image.png"), "image/png")]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentUriReturnsFalse()
{
ChatMessage a = new(ChatRole.User, [new UriContent(new Uri("https://a.com/x"), "image/png")]);
ChatMessage b = new(ChatRole.User, [new UriContent(new Uri("https://b.com/x"), "image/png")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentUriMediaTypeReturnsFalse()
{
Uri uri = new("https://example.com/file");
ChatMessage a = new(ChatRole.User, [new UriContent(uri, "image/png")]);
ChatMessage b = new(ChatRole.User, [new UriContent(uri, "image/jpeg")]);
Assert.False(a.ContentEquals(b));
}
#endregion
#region ErrorContent
[Fact]
public void EqualErrorContentReturnsTrue()
{
ChatMessage a = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]);
ChatMessage b = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentErrorMessageReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new ErrorContent("fail")]);
ChatMessage b = new(ChatRole.Assistant, [new ErrorContent("crash")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentErrorCodeReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]);
ChatMessage b = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E002" }]);
Assert.False(a.ContentEquals(b));
}
#endregion
#region FunctionCallContent
[Fact]
public void EqualFunctionCallContentReturnsTrue()
{
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather") { Arguments = new Dictionary<string, object?> { ["city"] = "Seattle" } }]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather") { Arguments = new Dictionary<string, object?> { ["city"] = "Seattle" } }]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentCallIdReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather")]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-2", "get_weather")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentFunctionNameReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather")]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_time")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentArgumentsReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["x"] = "1" } }]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["x"] = "2" } }]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void NullArgumentsBothSidesReturnsTrue()
{
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn")]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn")]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void OneNullArgumentsReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn")]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["x"] = "1" } }]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentArgumentCountReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["x"] = "1" } }]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["x"] = "1", ["y"] = "2" } }]);
Assert.False(a.ContentEquals(b));
}
#endregion
#region FunctionResultContent
[Fact]
public void EqualFunctionResultContentReturnsTrue()
{
ChatMessage a = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]);
ChatMessage b = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentResultCallIdReturnsFalse()
{
ChatMessage a = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]);
ChatMessage b = new(ChatRole.Tool, [new FunctionResultContent("call-2", "sunny")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentResultValueReturnsFalse()
{
ChatMessage a = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]);
ChatMessage b = new(ChatRole.Tool, [new FunctionResultContent("call-1", "rainy")]);
Assert.False(a.ContentEquals(b));
}
#endregion
#region HostedFileContent
[Fact]
public void EqualHostedFileContentReturnsTrue()
{
ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/csv", Name = "data.csv" }]);
ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/csv", Name = "data.csv" }]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentFileIdReturnsFalse()
{
ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc")]);
ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-xyz")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentHostedFileMediaTypeReturnsFalse()
{
ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/csv" }]);
ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/plain" }]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentHostedFileNameReturnsFalse()
{
ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc") { Name = "a.csv" }]);
ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-abc") { Name = "b.csv" }]);
Assert.False(a.ContentEquals(b));
}
#endregion
#region Content list structure
[Fact]
public void DifferentContentCountReturnsFalse()
{
ChatMessage a = new(ChatRole.User, [new TextContent("one"), new TextContent("two")]);
ChatMessage b = new(ChatRole.User, [new TextContent("one")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void MixedContentTypesInSameOrderReturnsTrue()
{
ChatMessage a = new(ChatRole.Assistant, new AIContent[] { new TextContent("reply"), new FunctionCallContent("c1", "fn") });
ChatMessage b = new(ChatRole.Assistant, new AIContent[] { new TextContent("reply"), new FunctionCallContent("c1", "fn") });
Assert.True(a.ContentEquals(b));
}
[Fact]
public void MismatchedContentTypeOrderReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, new AIContent[] { new TextContent("reply"), new FunctionCallContent("c1", "fn") });
ChatMessage b = new(ChatRole.Assistant, new AIContent[] { new FunctionCallContent("c1", "fn"), new TextContent("reply") });
Assert.False(a.ContentEquals(b));
}
[Fact]
public void EmptyContentsListsAreEqual()
{
ChatMessage a = new() { Role = ChatRole.User, Contents = [] };
ChatMessage b = new() { Role = ChatRole.User, Contents = [] };
Assert.True(a.ContentEquals(b));
}
[Fact]
public void SameContentItemReferenceReturnsTrue()
{
// Exercises the ReferenceEquals fast-path on individual AIContent items.
TextContent shared = new("Hello");
ChatMessage a = new(ChatRole.User, [shared]);
ChatMessage b = new(ChatRole.User, [shared]);
Assert.True(a.ContentEquals(b));
}
#endregion
#region Unknown AIContent subtype
[Fact]
public void UnknownContentSubtypeSameTypeReturnsTrue()
{
// Unknown subtypes with the same concrete type are considered equal.
ChatMessage a = new(ChatRole.User, [new StubContent()]);
ChatMessage b = new(ChatRole.User, [new StubContent()]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentUnknownContentSubtypesReturnFalse()
{
ChatMessage a = new(ChatRole.User, [new StubContent()]);
ChatMessage b = new(ChatRole.User, [new OtherStubContent()]);
Assert.False(a.ContentEquals(b));
}
private sealed class StubContent : AIContent;
private sealed class OtherStubContent : AIContent;
#endregion
}
@@ -0,0 +1,255 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="ChatReducerCompactionStrategy"/> class.
/// </summary>
public class ChatReducerCompactionStrategyTests
{
[Fact]
public void ConstructorNullReducerThrows()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new ChatReducerCompactionStrategy(null!, CompactionTriggers.Always));
}
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger never fires
TestChatReducer reducer = new(messages => messages.Take(1));
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Never);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(0, reducer.CallCount);
Assert.Equal(2, index.IncludedGroupCount);
}
[Fact]
public async Task CompactAsyncReducerReturnsFewerMessagesRebuildsIndexAsync()
{
// Arrange — reducer keeps only the last message
TestChatReducer reducer = new(messages => messages.Skip(messages.Count() - 1));
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.True(result);
Assert.Equal(1, reducer.CallCount);
Assert.Equal(1, index.IncludedGroupCount);
Assert.Equal("Second", index.Groups[0].Messages[0].Text);
}
[Fact]
public async Task CompactAsyncReducerReturnsSameCountReturnsFalseAsync()
{
// Arrange — reducer returns all messages (no reduction)
TestChatReducer reducer = new(messages => messages);
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(1, reducer.CallCount);
Assert.Equal(2, index.IncludedGroupCount);
}
[Fact]
public async Task CompactAsyncEmptyIndexReturnsFalseAsync()
{
// Arrange — no included messages
TestChatReducer reducer = new(messages => messages);
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
CompactionMessageIndex index = CompactionMessageIndex.Create([]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(0, reducer.CallCount);
}
[Fact]
public async Task CompactAsyncPreservesSystemMessagesWhenReducerKeepsThemAsync()
{
// Arrange — reducer keeps system + last user message
TestChatReducer reducer = new(messages =>
{
var nonSystem = messages.Where(m => m.Role != ChatRole.System).ToList();
return messages.Where(m => m.Role == ChatRole.System)
.Concat(nonSystem.Skip(nonSystem.Count - 1));
});
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.True(result);
Assert.Equal(2, index.IncludedGroupCount);
Assert.Equal(CompactionGroupKind.System, index.Groups[0].Kind);
Assert.Equal("You are helpful.", index.Groups[0].Messages[0].Text);
Assert.Equal(CompactionGroupKind.User, index.Groups[1].Kind);
Assert.Equal("Second", index.Groups[1].Messages[0].Text);
}
[Fact]
public async Task CompactAsyncRebuildsToolCallGroupsCorrectlyAsync()
{
// Arrange — reducer keeps last 3 messages (assistant tool call + tool result + user)
TestChatReducer reducer = new(messages => messages.Skip(messages.Count() - 3));
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Old question"),
new ChatMessage(ChatRole.Assistant, "Old answer"),
assistantToolCall,
toolResult,
new ChatMessage(ChatRole.User, "New question"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.True(result);
// Should have 2 groups: ToolCall group (assistant + tool result) + User group
Assert.Equal(2, index.IncludedGroupCount);
Assert.Equal(CompactionGroupKind.ToolCall, index.Groups[0].Kind);
Assert.Equal(2, index.Groups[0].Messages.Count);
Assert.Equal(CompactionGroupKind.User, index.Groups[1].Kind);
}
[Fact]
public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync()
{
// Arrange — one group is pre-excluded, reducer keeps last message
TestChatReducer reducer = new(messages => messages.Skip(messages.Count() - 1));
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Excluded"),
new ChatMessage(ChatRole.User, "Included 1"),
new ChatMessage(ChatRole.User, "Included 2"),
]);
index.Groups[0].IsExcluded = true;
// Act
bool result = await strategy.CompactAsync(index);
// Assert — reducer only saw 2 included messages, kept 1
Assert.True(result);
Assert.Equal(1, index.IncludedGroupCount);
Assert.Equal("Included 2", index.Groups[0].Messages[0].Text);
}
[Fact]
public async Task CompactAsyncExposesReducerPropertyAsync()
{
// Arrange
TestChatReducer reducer = new(messages => messages);
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
// Assert
Assert.Same(reducer, strategy.ChatReducer);
await Task.CompletedTask;
}
[Fact]
public async Task CompactAsyncPassesCancellationTokenToReducerAsync()
{
// Arrange
using CancellationTokenSource cancellationSource = new();
CancellationToken capturedToken = default;
TestChatReducer reducer = new((messages, cancellationToken) =>
{
capturedToken = cancellationToken;
return Task.FromResult<IEnumerable<ChatMessage>>(messages.Skip(messages.Count() - 1).ToList());
});
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
await strategy.CompactAsync(index, logger: null, cancellationSource.Token);
// Assert
Assert.Equal(cancellationSource.Token, capturedToken);
}
/// <summary>
/// A test implementation of <see cref="IChatReducer"/> that applies a configurable reduction function.
/// </summary>
private sealed class TestChatReducer : IChatReducer
{
private readonly Func<IEnumerable<ChatMessage>, CancellationToken, Task<IEnumerable<ChatMessage>>> _reduceFunc;
public TestChatReducer(Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> reduceFunc)
{
this._reduceFunc = (messages, _) => Task.FromResult(reduceFunc(messages));
}
public TestChatReducer(Func<IEnumerable<ChatMessage>, CancellationToken, Task<IEnumerable<ChatMessage>>> reduceFunc)
{
this._reduceFunc = reduceFunc;
}
public int CallCount { get; private set; }
public async Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
{
this.CallCount++;
return await this._reduceFunc(messages, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,159 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="ChatStrategyExtensions"/> class.
/// </summary>
public class ChatStrategyExtensionsTests
{
[Fact]
public void AsChatReducerNullStrategyThrows()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => ((CompactionStrategy)null!).AsChatReducer());
}
[Fact]
public void AsChatReducerReturnsIChatReducer()
{
// Arrange
ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Always);
// Act
IChatReducer reducer = strategy.AsChatReducer();
// Assert
Assert.NotNull(reducer);
}
[Fact]
public async Task ReduceAsyncReturnsAllMessagesWhenStrategyDoesNotCompactAsync()
{
// Arrange — trigger never fires, so no compaction occurs
ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Never);
IChatReducer reducer = strategy.AsChatReducer();
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi!"),
];
// Act
IEnumerable<ChatMessage> result = await reducer.ReduceAsync(messages, CancellationToken.None);
// Assert
Assert.Equal(messages, result);
}
[Fact]
public async Task ReduceAsyncCompactsMessagesWhenStrategyFiresAsync()
{
// Arrange — reducer keeps only the last message
ChatReducerCompactionStrategy strategy = new(
new TakeLastReducer(1),
CompactionTriggers.Always);
IChatReducer reducer = strategy.AsChatReducer();
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.Assistant, "Response 1"),
new(ChatRole.User, "Second"),
];
// Act
IEnumerable<ChatMessage> result = await reducer.ReduceAsync(messages, CancellationToken.None);
// Assert
List<ChatMessage> resultList = [.. result];
Assert.Single(resultList);
Assert.Equal("Second", resultList[0].Text);
}
[Fact]
public async Task ReduceAsyncPassesCancellationTokenToStrategyAsync()
{
// Arrange
using CancellationTokenSource cts = new();
CancellationToken capturedToken = default;
CapturingReducer capturingReducer = new(token => capturedToken = token);
ChatReducerCompactionStrategy strategy = new(capturingReducer, CompactionTriggers.Always);
IChatReducer reducer = strategy.AsChatReducer();
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.User, "World"),
];
// Act
await reducer.ReduceAsync(messages, cts.Token);
// Assert
Assert.Equal(cts.Token, capturedToken);
}
[Fact]
public async Task ReduceAsyncEmptyMessagesReturnsEmptyAsync()
{
// Arrange
ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Always);
IChatReducer reducer = strategy.AsChatReducer();
// Act
IEnumerable<ChatMessage> result = await reducer.ReduceAsync([], CancellationToken.None);
// Assert
Assert.Empty(result);
}
/// <summary>
/// An <see cref="IChatReducer"/> that returns messages unchanged.
/// </summary>
private sealed class IdentityReducer : IChatReducer
{
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
=> Task.FromResult(messages);
}
/// <summary>
/// An <see cref="IChatReducer"/> that keeps only the last <c>n</c> messages.
/// </summary>
private sealed class TakeLastReducer : IChatReducer
{
private readonly int _count;
public TakeLastReducer(int count) => this._count = count;
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
=> Task.FromResult(messages.Reverse().Take(this._count));
}
/// <summary>
/// An <see cref="IChatReducer"/> that captures the <see cref="CancellationToken"/> passed to <see cref="ReduceAsync"/>.
/// </summary>
private sealed class CapturingReducer : IChatReducer
{
private readonly Action<CancellationToken> _capture;
public CapturingReducer(Action<CancellationToken> capture) => this._capture = capture;
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
{
this._capture(cancellationToken);
IEnumerable<ChatMessage> reducedMessages = [messages.Reverse().First()];
return Task.FromResult(reducedMessages);
}
}
}
@@ -0,0 +1,447 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="CompactionProvider"/> class.
/// </summary>
public sealed class CompactionProviderTests
{
[Fact]
public void ConstructorThrowsOnNullStrategy()
{
Assert.Throws<ArgumentNullException>(() => new CompactionProvider(null!));
}
[Fact]
public void StateKeysReturnsExpectedKey()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
// Act & Assert — default state key is the strategy type name
Assert.Single(provider.StateKeys);
Assert.Equal(nameof(TruncationCompactionStrategy), provider.StateKeys[0]);
}
[Fact]
public void StateKeysAreStableAcrossEquivalentInstances()
{
// Arrange — two providers with equivalent (but distinct) strategies
CompactionProvider provider1 = new(new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(100000)));
CompactionProvider provider2 = new(new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(100000)));
// Act & Assert — default keys must be identical for session state stability
Assert.Equal(provider1.StateKeys[0], provider2.StateKeys[0]);
}
[Fact]
public void StateKeysReturnsCustomKeyWhenProvided()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy, stateKey: "my-custom-key");
// Act & Assert
Assert.Single(provider.StateKeys);
Assert.Equal("my-custom-key", provider.StateKeys[0]);
}
[Fact]
public async Task InvokingAsyncNoSessionPassesThroughAsync()
{
// Arrange — no session → passthrough
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
];
AIContextProvider.InvokingContext context = new(
mockAgent.Object,
session: null,
new AIContext { Messages = messages });
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — original context returned unchanged
Assert.Same(messages, result.Messages);
}
[Fact]
public async Task InvokingAsyncNullMessagesPassesThroughAsync()
{
// Arrange — messages is null → passthrough
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
AIContextProvider.InvokingContext context = new(
mockAgent.Object,
session,
new AIContext { Messages = null });
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — original context returned unchanged
Assert.Null(result.Messages);
}
[Fact]
public async Task InvokingAsyncAppliesCompactionWhenTriggeredAsync()
{
// Arrange — strategy that always triggers and keeps only 1 group
TruncationCompactionStrategy strategy = new(_ => true, minimumPreservedGroups: 1);
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
];
AIContextProvider.InvokingContext context = new(
mockAgent.Object,
session,
new AIContext { Messages = messages });
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — compaction should have reduced the message count
Assert.NotNull(result.Messages);
List<ChatMessage> resultList = [.. result.Messages!];
Assert.True(resultList.Count < messages.Count);
}
[Fact]
public async Task InvokingAsyncNoCompactionNeededReturnsOriginalMessagesAsync()
{
// Arrange — trigger never fires → no compaction
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
];
AIContextProvider.InvokingContext context = new(
mockAgent.Object,
session,
new AIContext { Messages = messages });
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — original messages passed through
Assert.NotNull(result.Messages);
List<ChatMessage> resultList = [.. result.Messages!];
Assert.Single(resultList);
Assert.Equal("Hello", resultList[0].Text);
}
[Fact]
public async Task InvokingAsyncPreservesInstructionsAndToolsAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
AITool[] tools = [AIFunctionFactory.Create(() => "tool", "MyTool")];
AIContextProvider.InvokingContext context = new(
mockAgent.Object,
session,
new AIContext
{
Instructions = "Be helpful",
Messages = messages,
Tools = tools
});
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — instructions and tools are preserved
Assert.Equal("Be helpful", result.Instructions);
Assert.Same(tools, result.Tools);
}
[Fact]
public async Task InvokingAsyncWithExistingIndexUpdatesAsync()
{
// Arrange — call twice to exercise the "existing index" path
TruncationCompactionStrategy strategy = new(_ => true, minimumPreservedGroups: 1);
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
List<ChatMessage> messages1 =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
];
AIContextProvider.InvokingContext context1 = new(
mockAgent.Object,
session,
new AIContext { Messages = messages1 });
// First call — initializes state
await provider.InvokingAsync(context1);
List<ChatMessage> messages2 =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
];
AIContextProvider.InvokingContext context2 = new(
mockAgent.Object,
session,
new AIContext { Messages = messages2 });
// Act — second call exercises the update path
AIContext result = await provider.InvokingAsync(context2);
// Assert
Assert.NotNull(result.Messages);
}
[Fact]
public async Task InvokingAsyncWithNonListEnumerableCreatesListCopyAsync()
{
// Arrange — pass IEnumerable (not List<ChatMessage>) to exercise the list copy branch
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
// Use an IEnumerable (not a List) to trigger the copy path
IEnumerable<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
AIContextProvider.InvokingContext context = new(
mockAgent.Object,
session,
new AIContext { Messages = messages });
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.NotNull(result.Messages);
List<ChatMessage> resultList = [.. result.Messages!];
Assert.Single(resultList);
Assert.Equal("Hello", resultList[0].Text);
}
[Fact]
public async Task CompactAsyncThrowsOnNullStrategyAsync()
{
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
await Assert.ThrowsAsync<ArgumentNullException>(() => CompactionProvider.CompactAsync(null!, messages));
}
[Fact]
public async Task CompactAsyncReturnsAllMessagesWhenTriggerDoesNotFireAsync()
{
// Arrange — trigger never fires → no compaction
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
];
// Act
IEnumerable<ChatMessage> result = await CompactionProvider.CompactAsync(strategy, messages);
// Assert — all messages preserved
List<ChatMessage> resultList = [.. result];
Assert.Equal(messages.Count, resultList.Count);
Assert.Equal("Q1", resultList[0].Text);
Assert.Equal("A1", resultList[1].Text);
Assert.Equal("Q2", resultList[2].Text);
}
[Fact]
public async Task CompactAsyncReducesMessagesWhenTriggeredAsync()
{
// Arrange — strategy that always triggers and keeps only 1 group
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
];
// Act
IEnumerable<ChatMessage> result = await CompactionProvider.CompactAsync(strategy, messages);
// Assert — compaction should have reduced the message count
List<ChatMessage> resultList = [.. result];
Assert.True(resultList.Count < messages.Count);
}
[Fact]
public async Task CompactAsyncHandlesEmptyMessageListAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
List<ChatMessage> messages = [];
// Act
IEnumerable<ChatMessage> result = await CompactionProvider.CompactAsync(strategy, messages);
// Assert
Assert.Empty(result);
}
[Fact]
public async Task CompactAsyncWorksWithNonListEnumerableAsync()
{
// Arrange — IEnumerable (not a List<ChatMessage>) to exercise the list copy branch
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
IEnumerable<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
// Act
IEnumerable<ChatMessage> result = await CompactionProvider.CompactAsync(strategy, messages);
// Assert
List<ChatMessage> resultList = [.. result];
Assert.Single(resultList);
Assert.Equal("Hello", resultList[0].Text);
}
[Fact]
public void CompactionStateAssignment()
{
// Arrange
CompactionProvider.State state = new();
// Assert
Assert.NotNull(state.MessageGroups);
Assert.Empty(state.MessageGroups);
// Act
state.MessageGroups = [new CompactionMessageGroup(CompactionGroupKind.User, [], 0, 0, 0)];
// Assert
Assert.Single(state.MessageGroups);
}
[Fact]
public async Task InvokingAsyncMarksOnlyPreviouslySeenMessagesAsChatHistoryAsync()
{
// Arrange — no-compaction strategy so we can observe marking behavior only
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
// --- First invocation: [Q1, A1, Q2] ---
ChatMessage q1 = new(ChatRole.User, "Q1");
ChatMessage a1 = new(ChatRole.Assistant, "A1");
ChatMessage q2 = new(ChatRole.User, "Q2");
AIContextProvider.InvokingContext context1 = new(
mockAgent.Object,
session,
new AIContext { Messages = new List<ChatMessage> { q1, a1, q2 } });
AIContext result1 = await provider.InvokingAsync(context1);
// Assert — on first invocation, no messages should be marked as ChatHistory
List<ChatMessage> resultList1 = [.. result1.Messages!];
Assert.Equal(3, resultList1.Count);
foreach (ChatMessage message in resultList1)
{
Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, message.GetAgentRequestMessageSourceType());
}
// --- Second invocation: [Q1, A1, Q2, A2, Q3] ---
ChatMessage a2 = new(ChatRole.Assistant, "A2");
ChatMessage q3 = new(ChatRole.User, "Q3");
AIContextProvider.InvokingContext context2 = new(
mockAgent.Object,
session,
new AIContext { Messages = new List<ChatMessage> { q1, a1, q2, a2, q3 } });
AIContext result2 = await provider.InvokingAsync(context2);
// Assert — messages from the first invocation should be marked as ChatHistory,
// while new messages should not.
List<ChatMessage> resultList2 = [.. result2.Messages!];
Assert.Equal(5, resultList2.Count);
// Q1, A1, Q2 were already in the provider state — they should be ChatHistory
Assert.Equal(AgentRequestMessageSourceType.ChatHistory, resultList2[0].GetAgentRequestMessageSourceType());
Assert.Equal(AgentRequestMessageSourceType.ChatHistory, resultList2[1].GetAgentRequestMessageSourceType());
Assert.Equal(AgentRequestMessageSourceType.ChatHistory, resultList2[2].GetAgentRequestMessageSourceType());
// A2, Q3 are new to the provider — they should NOT be ChatHistory
Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList2[3].GetAgentRequestMessageSourceType());
Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList2[4].GetAgentRequestMessageSourceType());
// --- Third invocation: [Q1, A1, Q2, A2, Q3, A3, Q4] ---
ChatMessage a3 = new(ChatRole.Assistant, "A3");
ChatMessage q4 = new(ChatRole.User, "Q4");
AIContextProvider.InvokingContext context3 = new(
mockAgent.Object,
session,
new AIContext { Messages = new List<ChatMessage> { q1, a1, q2, a2, q3, a3, q4 } });
AIContext result3 = await provider.InvokingAsync(context3);
// Assert — all previously seen messages should be ChatHistory, only brand-new ones should not
List<ChatMessage> resultList3 = [.. result3.Messages!];
Assert.Equal(7, resultList3.Count);
// Q1, A1, Q2, A2, Q3 were already in the provider state — they should be ChatHistory
for (int i = 0; i < 5; i++)
{
Assert.Equal(AgentRequestMessageSourceType.ChatHistory, resultList3[i].GetAgentRequestMessageSourceType());
}
// A3, Q4 are new — they should NOT be ChatHistory
Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList3[5].GetAgentRequestMessageSourceType());
Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList3[6].GetAgentRequestMessageSourceType());
}
private sealed class TestAgentSession : AgentSession;
}
@@ -0,0 +1,236 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="CompactionStrategy"/> abstract base class.
/// </summary>
public class CompactionStrategyTests
{
[Fact]
public void ConstructorNullTriggerThrows()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new TestStrategy(null!));
}
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger never fires, but enough non-system groups to pass short-circuit
TestStrategy strategy = new(_ => false);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(0, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncTriggerMetCallsApplyAsync()
{
// Arrange — trigger always fires, enough non-system groups
TestStrategy strategy = new(_ => true, applyFunc: _ => true);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.True(result);
Assert.Equal(1, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncReturnsFalseWhenApplyReturnsFalseAsync()
{
// Arrange — trigger fires but Apply does nothing
TestStrategy strategy = new(_ => true, applyFunc: _ => false);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(1, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncSingleNonSystemGroupShortCircuitsAsync()
{
// Arrange — trigger would fire, but only 1 non-system group → short-circuit
TestStrategy strategy = new(_ => true, applyFunc: _ => true);
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — short-circuited before trigger or Apply
Assert.False(result);
Assert.Equal(0, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncSingleNonSystemGroupWithSystemShortCircuitsAsync()
{
// Arrange — system group + 1 non-system group → still short-circuits
TestStrategy strategy = new(_ => true, applyFunc: _ => true);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Hello"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — system groups don't count, still only 1 non-system group
Assert.False(result);
Assert.Equal(0, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncTwoNonSystemGroupsProceedsToTriggerAsync()
{
// Arrange — exactly 2 non-system groups: boundary passes, trigger fires
TestStrategy strategy = new(_ => true, applyFunc: _ => true);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — not short-circuited, Apply was called
Assert.True(result);
Assert.Equal(1, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncDefaultTargetIsInverseOfTriggerAsync()
{
// Arrange — trigger fires when groups > 2
// Default target should be: stop when groups <= 2 (i.e., !trigger)
CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2);
TestStrategy strategy = new(trigger, applyFunc: index =>
{
// Exclude oldest non-system group one at a time
foreach (CompactionMessageGroup group in index.Groups)
{
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System)
{
group.IsExcluded = true;
// Target (default = !trigger) returns true when groups <= 2
// So the strategy would check Target after this exclusion
break;
}
}
return true;
});
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — trigger fires (4 > 2), Apply is called
Assert.True(result);
Assert.Equal(1, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncCustomTargetIsPassedToStrategyAsync()
{
// Arrange — custom target that always signals stop
bool targetCalled = false;
bool CustomTarget(CompactionMessageIndex _)
{
targetCalled = true;
return true;
}
TestStrategy strategy = new(_ => true, CustomTarget, _ =>
{
// Access the target from within the strategy
return true;
});
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — the custom target is accessible (verified by TestStrategy checking it)
Assert.Equal(1, strategy.ApplyCallCount);
// The target is accessible to derived classes via the protected property
Assert.True(strategy.InvokeTarget(index));
Assert.True(targetCalled);
}
/// <summary>
/// A concrete test implementation of <see cref="CompactionStrategy"/> for testing the base class.
/// </summary>
private sealed class TestStrategy : CompactionStrategy
{
private readonly Func<CompactionMessageIndex, bool>? _applyFunc;
public TestStrategy(
CompactionTrigger trigger,
CompactionTrigger? target = null,
Func<CompactionMessageIndex, bool>? applyFunc = null)
: base(trigger, target)
{
this._applyFunc = applyFunc;
}
public int ApplyCallCount { get; private set; }
/// <summary>
/// Exposes the protected Target property for test verification.
/// </summary>
public bool InvokeTarget(CompactionMessageIndex index) => this.Target(index);
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
this.ApplyCallCount++;
bool result = this._applyFunc?.Invoke(index) ?? false;
return new(result);
}
}
}
@@ -0,0 +1,180 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for <see cref="CompactionTrigger"/> and <see cref="CompactionTriggers"/>.
/// </summary>
public class CompactionTriggersTests
{
[Fact]
public void TokensExceedReturnsTrueWhenAboveThreshold()
{
// Arrange — use a long message to guarantee tokens > 0
CompactionTrigger trigger = CompactionTriggers.TokensExceed(0);
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
// Act & Assert
Assert.True(trigger(index));
}
[Fact]
public void TokensExceedReturnsFalseWhenBelowThreshold()
{
CompactionTrigger trigger = CompactionTriggers.TokensExceed(999_999);
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
Assert.False(trigger(index));
}
[Fact]
public void MessagesExceedReturnsExpectedResult()
{
CompactionTrigger trigger = CompactionTriggers.MessagesExceed(2);
CompactionMessageIndex small = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.User, "B"),
]);
CompactionMessageIndex large = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.User, "B"),
new ChatMessage(ChatRole.User, "C"),
]);
Assert.False(trigger(small));
Assert.True(trigger(large));
}
[Fact]
public void TurnsExceedReturnsExpectedResult()
{
CompactionTrigger trigger = CompactionTriggers.TurnsExceed(1);
CompactionMessageIndex oneTurn = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
]);
CompactionMessageIndex twoTurns = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
Assert.False(trigger(oneTurn));
Assert.True(trigger(twoTurns));
}
[Fact]
public void GroupsExceedReturnsExpectedResult()
{
CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.Assistant, "B"),
new ChatMessage(ChatRole.User, "C"),
]);
Assert.True(trigger(index));
}
[Fact]
public void HasToolCallsReturnsTrueWhenToolCallGroupExists()
{
CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.Tool, "result"),
]);
Assert.True(trigger(index));
}
[Fact]
public void HasToolCallsReturnsFalseWhenNoToolCallGroup()
{
CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
Assert.False(trigger(index));
}
[Fact]
public void AllRequiresAllConditions()
{
CompactionTrigger trigger = CompactionTriggers.All(
CompactionTriggers.TokensExceed(0),
CompactionTriggers.MessagesExceed(5));
CompactionMessageIndex small = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
// Tokens > 0 is true, but messages > 5 is false
Assert.False(trigger(small));
}
[Fact]
public void AnyRequiresAtLeastOneCondition()
{
CompactionTrigger trigger = CompactionTriggers.Any(
CompactionTriggers.TokensExceed(999_999),
CompactionTriggers.MessagesExceed(0));
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
// Tokens not exceeded, but messages > 0 is true
Assert.True(trigger(index));
}
[Fact]
public void AllEmptyTriggersReturnsTrue()
{
CompactionTrigger trigger = CompactionTriggers.All();
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
Assert.True(trigger(index));
}
[Fact]
public void AnyEmptyTriggersReturnsFalse()
{
CompactionTrigger trigger = CompactionTriggers.Any();
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
Assert.False(trigger(index));
}
[Fact]
public void TokensBelowReturnsTrueWhenBelowThreshold()
{
CompactionTrigger trigger = CompactionTriggers.TokensBelow(999_999);
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
Assert.True(trigger(index));
}
[Fact]
public void TokensBelowReturnsFalseWhenAboveThreshold()
{
CompactionTrigger trigger = CompactionTriggers.TokensBelow(0);
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
Assert.False(trigger(index));
}
[Fact]
public void AlwaysReturnsTrue()
{
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
Assert.True(CompactionTriggers.Always(index));
}
}
@@ -0,0 +1,219 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="ContextWindowCompactionStrategy"/> class.
/// </summary>
public class ContextWindowCompactionStrategyTests
{
[Fact]
public void Constructor_ValidParameters_SetsProperties()
{
// Arrange & Act
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1_050_000,
maxOutputTokens: 128_000);
// Assert
Assert.Equal(1_050_000, strategy.MaxContextWindowTokens);
Assert.Equal(128_000, strategy.MaxOutputTokens);
Assert.Equal(922_000, strategy.InputBudgetTokens);
Assert.Equal(ContextWindowCompactionStrategy.DefaultToolEvictionThreshold, strategy.ToolEvictionThreshold);
Assert.Equal(ContextWindowCompactionStrategy.DefaultTruncationThreshold, strategy.TruncationThreshold);
}
[Fact]
public void Constructor_CustomThresholds_SetsProperties()
{
// Arrange & Act
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1_000_000,
maxOutputTokens: 100_000,
toolEvictionThreshold: 0.3,
truncationThreshold: 0.6);
// Assert
Assert.Equal(900_000, strategy.InputBudgetTokens);
Assert.Equal(0.3, strategy.ToolEvictionThreshold);
Assert.Equal(0.6, strategy.TruncationThreshold);
}
[Theory]
[InlineData(0, 100)] // maxContextWindowTokens <= 0
[InlineData(-1, 100)] // maxContextWindowTokens negative
public void Constructor_InvalidContextWindow_Throws(int contextWindow, int maxOutput)
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ContextWindowCompactionStrategy(contextWindow, maxOutput));
}
[Theory]
[InlineData(1000, -1)] // maxOutputTokens negative
[InlineData(1000, 1000)] // maxOutputTokens == contextWindow
[InlineData(1000, 1001)] // maxOutputTokens > contextWindow
public void Constructor_InvalidOutputTokens_Throws(int contextWindow, int maxOutput)
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ContextWindowCompactionStrategy(contextWindow, maxOutput));
}
[Theory]
[InlineData(0.0)] // Zero threshold
[InlineData(-0.1)] // Negative threshold
[InlineData(1.1)] // Over 1.0
public void Constructor_InvalidToolEvictionThreshold_Throws(double threshold)
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ContextWindowCompactionStrategy(1000, 100, toolEvictionThreshold: threshold));
}
[Theory]
[InlineData(0.0)] // Zero threshold
[InlineData(-0.1)] // Negative threshold
[InlineData(1.1)] // Over 1.0
public void Constructor_InvalidTruncationThreshold_Throws(double threshold)
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ContextWindowCompactionStrategy(1000, 100, truncationThreshold: threshold));
}
[Fact]
public void Constructor_TruncationBelowToolEviction_Throws()
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ContextWindowCompactionStrategy(1000, 100, toolEvictionThreshold: 0.8, truncationThreshold: 0.5));
}
[Fact]
public async Task CompactAsync_BelowToolEvictionThreshold_NoCompactionAsync()
{
// Arrange — input budget = 900 tokens, tool eviction at 450, truncation at 720
// A few short messages should be well below any threshold.
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1000,
maxOutputTokens: 100);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi there!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(2, index.IncludedGroupCount);
}
[Fact]
public async Task CompactAsync_AboveTruncationThreshold_TruncatesOldestAsync()
{
// Arrange — use a budget of 5 tokens with truncation at 80% = 4 token threshold.
// Even the shortest messages will exceed this, ensuring truncation fires.
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 10,
maxOutputTokens: 5,
toolEvictionThreshold: 0.5,
truncationThreshold: 0.8);
// Verify internal budget calculation
Assert.Equal(5, strategy.InputBudgetTokens);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First user message"),
new ChatMessage(ChatRole.Assistant, "First response"),
new ChatMessage(ChatRole.User, "Second user message"),
new ChatMessage(ChatRole.Assistant, "Second response"),
]);
int groupsBefore = index.IncludedGroupCount;
int tokensBefore = index.IncludedTokenCount;
// Verify tokens actually exceed the truncation threshold (80% of 5 = 4)
Assert.True(tokensBefore > 4, $"Expected tokens > 4 but got {tokensBefore}");
Assert.True(groupsBefore > 1, $"Expected groups > 1 but got {groupsBefore}");
// Act
bool result = await strategy.CompactAsync(index);
// Assert — with tokens well above a 4-token threshold, truncation should fire
Assert.True(result, $"Expected compaction to occur. Tokens before: {tokensBefore}, groups before: {groupsBefore}, NonSystemGroups: {index.IncludedNonSystemGroupCount}");
Assert.True(index.IncludedGroupCount < groupsBefore);
}
[Fact]
public async Task CompactAsync_ToolCallsAboveEvictionThreshold_CollapsesToolCallsAsync()
{
// Arrange — very small budget so tool eviction fires.
// Input budget = 5, tool eviction at 50% = 2 token threshold.
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 10,
maxOutputTokens: 5,
toolEvictionThreshold: 0.5,
truncationThreshold: 0.9);
// Build messages with a tool call group: assistant with FunctionCallContent + tool result
var assistantMessage = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_data", arguments: new Dictionary<string, object?> { ["query"] = "test" })]);
var toolResultMessage = new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "Here is a long result with many words to ensure we exceed the token threshold")]);
var userMessage = new ChatMessage(ChatRole.User, "What did you find?");
var assistantResponse = new ChatMessage(ChatRole.Assistant, "Based on the results I found information.");
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
assistantMessage,
toolResultMessage,
userMessage,
assistantResponse,
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — compaction should succeed for tool calls above the eviction threshold.
// Do not assert on IncludedTokenCount because tool-result compaction preserves content
// in summary form and tokenization can make the count stay the same or increase.
Assert.True(result);
}
[Fact]
public void Constructor_EqualThresholds_Succeeds()
{
// Arrange & Act — truncation == tool eviction should be valid
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1000,
maxOutputTokens: 100,
toolEvictionThreshold: 0.7,
truncationThreshold: 0.7);
// Assert
Assert.Equal(0.7, strategy.ToolEvictionThreshold);
Assert.Equal(0.7, strategy.TruncationThreshold);
}
[Fact]
public void Constructor_ZeroMaxOutputTokens_FullBudget()
{
// Arrange & Act
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1_000_000,
maxOutputTokens: 0);
// Assert — entire context window is the input budget
Assert.Equal(1_000_000, strategy.InputBudgetTokens);
}
}
@@ -0,0 +1,208 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="PipelineCompactionStrategy"/> class.
/// </summary>
public class PipelineCompactionStrategyTests
{
[Fact]
public async Task CompactAsyncExecutesAllStrategiesInOrderAsync()
{
// Arrange
List<string> executionOrder = [];
TestCompactionStrategy strategy1 = new(
_ =>
{
executionOrder.Add("first");
return false;
});
TestCompactionStrategy strategy2 = new(
_ =>
{
executionOrder.Add("second");
return false;
});
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
await pipeline.CompactAsync(groups);
// Assert
Assert.Equal(["first", "second"], executionOrder);
}
[Fact]
public async Task CompactAsyncReturnsFalseWhenNoStrategyCompactsAsync()
{
// Arrange
TestCompactionStrategy strategy1 = new(_ => false);
PipelineCompactionStrategy pipeline = new(strategy1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncReturnsTrueWhenAnyStrategyCompactsAsync()
{
// Arrange
TestCompactionStrategy strategy1 = new(_ => false);
TestCompactionStrategy strategy2 = new(_ => true);
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.True(result);
}
[Fact]
public async Task CompactAsyncContinuesAfterFirstCompactionAsync()
{
// Arrange
TestCompactionStrategy strategy1 = new(_ => true);
TestCompactionStrategy strategy2 = new(_ => false);
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
await pipeline.CompactAsync(groups);
// Assert — both strategies were called
Assert.Equal(1, strategy1.ApplyCallCount);
Assert.Equal(1, strategy2.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncComposesStrategiesEndToEndAsync()
{
// Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more
static void ExcludeOldest2(CompactionMessageIndex index)
{
int excluded = 0;
foreach (CompactionMessageGroup group in index.Groups)
{
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System && excluded < 2)
{
group.IsExcluded = true;
excluded++;
}
}
}
TestCompactionStrategy phase1 = new(
index =>
{
ExcludeOldest2(index);
return true;
});
TestCompactionStrategy phase2 = new(
index =>
{
ExcludeOldest2(index);
return true;
});
PipelineCompactionStrategy pipeline = new(phase1, phase2);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert — system is preserved, phase1 excluded Q1+A1, phase2 excluded Q2+A2 → System + Q3
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal(2, included.Count);
Assert.Equal("You are helpful.", included[0].Text);
Assert.Equal("Q3", included[1].Text);
Assert.Equal(1, phase1.ApplyCallCount);
Assert.Equal(1, phase2.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncEmptyPipelineReturnsFalseAsync()
{
// Arrange
PipelineCompactionStrategy pipeline = new(new List<CompactionStrategy>());
CompactionMessageIndex groups = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.False(result);
}
/// <summary>
/// A simple test implementation of <see cref="CompactionStrategy"/> that delegates to a synchronous callback.
/// </summary>
private sealed class TestCompactionStrategy : CompactionStrategy
{
private readonly Func<CompactionMessageIndex, bool> _applyFunc;
public TestCompactionStrategy(Func<CompactionMessageIndex, bool> applyFunc)
: base(CompactionTriggers.Always)
{
this._applyFunc = applyFunc;
}
public int ApplyCallCount { get; private set; }
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
this.ApplyCallCount++;
return new(this._applyFunc(index));
}
}
}
@@ -0,0 +1,311 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="SlidingWindowCompactionStrategy"/> class.
/// </summary>
public class SlidingWindowCompactionStrategyTests
{
[Fact]
public async Task CompactAsyncBelowMaxTurnsReturnsFalseAsync()
{
// Arrange — trigger requires > 3 turns, conversation has 2
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(3));
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncExceedsMaxTurnsExcludesOldestTurnsAsync()
{
// Arrange — trigger on > 2 turns, conversation has 3
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(2));
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
new ChatMessage(ChatRole.Assistant, "A3"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// Turn 1 (Q1 + A1) should be excluded
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
// Turn 2 and 3 should remain
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
Assert.False(groups.Groups[4].IsExcluded);
Assert.False(groups.Groups[5].IsExcluded);
}
[Fact]
public async Task CompactAsyncPreservesSystemMessagesAsync()
{
// Arrange — trigger on > 1 turn
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.False(groups.Groups[0].IsExcluded); // System preserved
Assert.True(groups.Groups[1].IsExcluded); // Turn 1 excluded
Assert.True(groups.Groups[2].IsExcluded); // Turn 1 response excluded
Assert.False(groups.Groups[3].IsExcluded); // Turn 2 kept
}
[Fact]
public async Task CompactAsyncPreservesToolCallGroupsInKeptTurnsAsync()
{
// Arrange — trigger on > 1 turn
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
new ChatMessage(ChatRole.Tool, "Results"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// Turn 1 excluded
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
// Turn 2 kept (user + tool call group)
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger requires > 99 turns
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(99));
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncIncludedMessagesContainOnlyKeptTurnsAsync()
{
// Arrange — trigger on > 1 turn
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System"),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal(3, included.Count);
Assert.Equal("System", included[0].Text);
Assert.Equal("Q2", included[1].Text);
Assert.Equal("A2", included[2].Text);
}
[Fact]
public async Task CompactAsyncCustomTargetStopsExcludingEarlyAsync()
{
// Arrange — trigger on > 1 turn, custom target stops after removing 1 turn
int removeCount = 0;
bool TargetAfterOne(CompactionMessageIndex _) => ++removeCount >= 1;
SlidingWindowCompactionStrategy strategy = new(
CompactionTriggers.TurnsExceed(1),
minimumPreservedTurns: 0,
target: TargetAfterOne);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
new ChatMessage(ChatRole.Assistant, "A3"),
new ChatMessage(ChatRole.User, "Q4"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — only turn 1 excluded (target stopped after 1 removal)
Assert.True(result);
Assert.True(index.Groups[0].IsExcluded); // Q1 (turn 1)
Assert.True(index.Groups[1].IsExcluded); // A1 (turn 1)
Assert.False(index.Groups[2].IsExcluded); // Q2 (turn 2) — kept
Assert.False(index.Groups[3].IsExcluded); // A2 (turn 2)
}
[Fact]
public async Task CompactAsyncMinimumPreservedStopsCompactionAsync()
{
// Arrange — always trigger with never-satisfied target, but MinimumPreserved = 2 is hard floor
SlidingWindowCompactionStrategy strategy = new(
CompactionTriggers.TurnsExceed(1),
minimumPreservedTurns: 2,
target: _ => false);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
new ChatMessage(ChatRole.Assistant, "A3"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — target never says stop, but MinimumPreserved=2 protects the last 2 turns
Assert.True(result);
Assert.Equal(4, index.IncludedGroupCount);
// Turn 1 excluded
Assert.True(index.Groups[0].IsExcluded); // Q1
Assert.True(index.Groups[1].IsExcluded); // A1
// Last 2 turns must be preserved
Assert.False(index.Groups[2].IsExcluded); // Q2
Assert.False(index.Groups[3].IsExcluded); // A2
Assert.False(index.Groups[4].IsExcluded); // Q3
Assert.False(index.Groups[5].IsExcluded); // A3
}
[Fact]
public async Task CompactAsyncSkipsExcludedAndSystemGroupsInEnumerationAsync()
{
// Arrange — includes system and pre-excluded groups that must be skipped
SlidingWindowCompactionStrategy strategy = new(
CompactionTriggers.TurnsExceed(1),
minimumPreservedTurns: 0);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System prompt"),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Pre-exclude one group
index.Groups[1].IsExcluded = true;
// Act
bool result = await strategy.CompactAsync(index);
// Assert — system preserved, pre-excluded skipped
Assert.True(result);
Assert.False(index.Groups[0].IsExcluded); // System preserved
}
[Fact]
public async Task CompactAsyncPreservesTurnIndexZeroAsync()
{
// Arrange — assistant message before first user turn gets TurnIndex = 0
SlidingWindowCompactionStrategy strategy = new(
CompactionTriggers.TurnsExceed(1),
minimumPreservedTurns: 0,
target: _ => false);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.Assistant, "Welcome!"), // TurnIndex = 0
new ChatMessage(ChatRole.User, "Q1"), // TurnIndex = 1
new ChatMessage(ChatRole.Assistant, "A1"), // TurnIndex = 1
new ChatMessage(ChatRole.User, "Q2"), // TurnIndex = 2
new ChatMessage(ChatRole.Assistant, "A2"), // TurnIndex = 2
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — TurnIndex = 0 is always preserved even with minimumPreservedTurns = 0
Assert.True(result);
Assert.False(index.Groups[0].IsExcluded); // Welcome (TurnIndex 0) preserved
Assert.True(index.Groups[1].IsExcluded); // Q1 (TurnIndex 1) excluded
Assert.True(index.Groups[2].IsExcluded); // A1 (TurnIndex 1) excluded
Assert.True(index.Groups[3].IsExcluded); // Q2 (TurnIndex 2) excluded
Assert.True(index.Groups[4].IsExcluded); // A2 (TurnIndex 2) excluded
}
[Fact]
public async Task CompactAsyncPreservesNullTurnIndexAsync()
{
// Arrange — system messages (TurnIndex = null) should never be removed
SlidingWindowCompactionStrategy strategy = new(
CompactionTriggers.TurnsExceed(0),
minimumPreservedTurns: 0,
target: _ => false);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — system message (TurnIndex null) always preserved
Assert.True(result);
Assert.False(index.Groups[0].IsExcluded); // System (TurnIndex null) preserved
Assert.True(index.Groups[1].IsExcluded); // Q1 excluded
Assert.True(index.Groups[2].IsExcluded); // A1 excluded
}
}
@@ -0,0 +1,613 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="SummarizationCompactionStrategy"/> class.
/// </summary>
public class SummarizationCompactionStrategyTests
{
/// <summary>
/// Creates a mock <see cref="IChatClient"/> that returns the specified summary text.
/// </summary>
private static IChatClient CreateMockChatClient(string summaryText = "Summary of conversation.")
{
Mock<IChatClient> mock = new();
mock.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, summaryText)]));
return mock.Object;
}
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger requires > 100000 tokens
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient(),
CompactionTriggers.TokensExceed(100000),
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(2, index.IncludedGroupCount);
}
[Fact]
public async Task CompactAsyncSummarizesOldGroupsAsync()
{
// Arrange — always trigger, preserve 1 recent group
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient("Key facts from earlier."),
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First question"),
new ChatMessage(ChatRole.Assistant, "First answer"),
new ChatMessage(ChatRole.User, "Second question"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.True(result);
List<ChatMessage> included = [.. index.GetIncludedMessages()];
// Should have: summary + preserved recent group (Second question)
Assert.Equal(2, included.Count);
Assert.Contains("[Summary]", included[0].Text);
Assert.Contains("Key facts from earlier.", included[0].Text);
Assert.Equal("Second question", included[1].Text);
}
[Fact]
public async Task CompactAsyncPreservesSystemMessagesAsync()
{
// Arrange
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient(),
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Old question"),
new ChatMessage(ChatRole.Assistant, "Old answer"),
new ChatMessage(ChatRole.User, "Recent question"),
]);
// Act
await strategy.CompactAsync(index);
// Assert
List<ChatMessage> included = [.. index.GetIncludedMessages()];
Assert.Equal("You are helpful.", included[0].Text);
Assert.Equal(ChatRole.System, included[0].Role);
}
[Fact]
public async Task CompactAsyncInsertsSummaryGroupAtCorrectPositionAsync()
{
// Arrange
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient("Summary text."),
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System prompt."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — summary should be inserted after system, before preserved group
CompactionMessageGroup summaryGroup = index.Groups.First(g => g.Kind == CompactionGroupKind.Summary);
Assert.NotNull(summaryGroup);
Assert.Contains("[Summary]", summaryGroup.Messages[0].Text);
Assert.True(summaryGroup.Messages[0].AdditionalProperties!.ContainsKey(CompactionMessageGroup.SummaryPropertyKey));
}
[Fact]
public async Task CompactAsyncHandlesEmptyLlmResponseAsync()
{
// Arrange — LLM returns whitespace
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient(" "),
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — should use fallback text
List<ChatMessage> included = [.. index.GetIncludedMessages()];
Assert.Contains("[Summary unavailable]", included[0].Text);
}
[Fact]
public async Task CompactAsyncNothingToSummarizeReturnsFalseAsync()
{
// Arrange — preserve 5 but only 2 non-system groups
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient(),
CompactionTriggers.Always,
minimumPreservedGroups: 5);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncUsesCustomPromptAsync()
{
// Arrange — capture the messages sent to the chat client
List<ChatMessage>? capturedMessages = null;
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
capturedMessages = [.. msgs])
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Custom summary.")]));
const string CustomPrompt = "Summarize in bullet points only.";
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1,
summarizationPrompt: CustomPrompt);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — the custom prompt should be the system message, followed by the original messages
Assert.NotNull(capturedMessages);
Assert.Equal(2, capturedMessages.Count);
Assert.Equal(ChatRole.System, capturedMessages![0].Role);
Assert.Equal(CustomPrompt, capturedMessages[0].Text);
Assert.Equal(ChatRole.User, capturedMessages[1].Role);
Assert.Equal("Q1", capturedMessages[1].Text);
}
[Fact]
public async Task CompactAsyncSetsExcludeReasonAsync()
{
// Arrange
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient(),
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Old"),
new ChatMessage(ChatRole.User, "New"),
]);
// Act
await strategy.CompactAsync(index);
// Assert
CompactionMessageGroup excluded = index.Groups.First(g => g.IsExcluded);
Assert.NotNull(excluded.ExcludeReason);
Assert.Contains("SummarizationCompactionStrategy", excluded.ExcludeReason);
}
[Fact]
public async Task CompactAsyncTargetStopsMarkingEarlyAsync()
{
// Arrange — 4 non-system groups, preserve 1, target met after 1 exclusion
int exclusionCount = 0;
bool TargetAfterOne(CompactionMessageIndex _) => ++exclusionCount >= 1;
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient("Partial summary."),
CompactionTriggers.Always,
minimumPreservedGroups: 1,
target: TargetAfterOne);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — only 1 group should have been summarized (target met after first exclusion)
int excludedCount = index.Groups.Count(g => g.IsExcluded);
Assert.Equal(1, excludedCount);
}
[Fact]
public async Task CompactAsyncPreservesMultipleRecentGroupsAsync()
{
// Arrange — preserve 2
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient("Summary."),
CompactionTriggers.Always,
minimumPreservedGroups: 2);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — 2 oldest excluded, 2 newest preserved + 1 summary inserted
List<ChatMessage> included = [.. index.GetIncludedMessages()];
Assert.Equal(3, included.Count); // summary + Q2 + A2
Assert.Contains("[Summary]", included[0].Text);
Assert.Equal("Q2", included[1].Text);
Assert.Equal("A2", included[2].Text);
}
[Fact]
public async Task CompactAsyncWithSystemBetweenSummarizableGroupsAsync()
{
// Arrange — system group between user/assistant groups to exercise skip logic in loop
IChatClient mockClient = CreateMockChatClient("[Summary]");
SummarizationCompactionStrategy strategy = new(
mockClient,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.System, "System note"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — summary inserted at 0, system group shifted to index 2
Assert.True(result);
Assert.Equal(CompactionGroupKind.Summary, index.Groups[0].Kind);
Assert.Equal(CompactionGroupKind.System, index.Groups[2].Kind);
Assert.False(index.Groups[2].IsExcluded); // System never excluded
}
[Fact]
public async Task CompactAsyncMaxSummarizableBoundsLoopExitAsync()
{
// Arrange — large MinimumPreserved so maxSummarizable is small, target never stops
IChatClient mockClient = CreateMockChatClient("[Summary]");
SummarizationCompactionStrategy strategy = new(
mockClient,
CompactionTriggers.Always,
minimumPreservedGroups: 3,
target: _ => false);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
new ChatMessage(ChatRole.Assistant, "A3"),
]);
// Act — should only summarize 6-3 = 3 groups (not all 6)
bool result = await strategy.CompactAsync(index);
// Assert — 3 preserved + 1 summary = 4 included
Assert.True(result);
Assert.Equal(4, index.IncludedGroupCount);
}
[Fact]
public async Task CompactAsyncWithPreExcludedGroupAsync()
{
// Arrange — pre-exclude a group so the count and loop both must skip it
IChatClient mockClient = CreateMockChatClient("[Summary]");
SummarizationCompactionStrategy strategy = new(
mockClient,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
index.Groups[0].IsExcluded = true; // Pre-exclude Q1
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.True(result);
Assert.True(index.Groups[0].IsExcluded); // Still excluded
}
[Fact]
public async Task CompactAsyncWithEmptyTextMessageInGroupAsync()
{
// Arrange — a message with null text (FunctionCallContent) in a summarized group
IChatClient mockClient = CreateMockChatClient("[Summary]");
SummarizationCompactionStrategy strategy = new(
mockClient,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
];
CompactionMessageIndex index = CompactionMessageIndex.Create(messages);
// Act — the tool-call group's message has null text
bool result = await strategy.CompactAsync(index);
// Assert — compaction succeeded despite null text
Assert.True(result);
}
#region Error resilience
[Fact]
public async Task CompactAsyncLlmFailureRestoresGroupsAsync()
{
// Arrange — chat client throws a non-cancellation exception
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Service unavailable"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
int originalGroupCount = index.Groups.Count;
// Act
bool result = await strategy.CompactAsync(index);
// Assert — returns false, all groups restored to non-excluded
Assert.False(result);
Assert.Equal(originalGroupCount, index.Groups.Count);
Assert.All(index.Groups, g => Assert.False(g.IsExcluded));
Assert.All(index.Groups, g => Assert.Null(g.ExcludeReason));
}
[Fact]
public async Task CompactAsyncLlmFailurePreservesAllOriginalMessagesAsync()
{
// Arrange — verify that after failure, GetIncludedMessages returns all original messages
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpRequestException("Timeout"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
List<ChatMessage> originalIncluded = [.. index.GetIncludedMessages()];
// Act
await strategy.CompactAsync(index);
// Assert — all original messages still included
List<ChatMessage> afterIncluded = [.. index.GetIncludedMessages()];
Assert.Equal(originalIncluded.Count, afterIncluded.Count);
for (int i = 0; i < originalIncluded.Count; i++)
{
Assert.Same(originalIncluded[i], afterIncluded[i]);
}
}
[Fact]
public async Task CompactAsyncLlmFailureDoesNotInsertSummaryGroupAsync()
{
// Arrange
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("API error"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — no Summary group was inserted
Assert.DoesNotContain(index.Groups, g => g.Kind == CompactionGroupKind.Summary);
}
[Fact]
public async Task CompactAsyncCancellationPropagatesAsync()
{
// Arrange — OperationCanceledException should NOT be caught
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException("Cancelled"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act & Assert — OperationCanceledException propagates
await Assert.ThrowsAsync<OperationCanceledException>(
() => strategy.CompactAsync(index).AsTask());
}
[Fact]
public async Task CompactAsyncTaskCancellationPropagatesAsync()
{
// Arrange — TaskCanceledException (subclass of OperationCanceledException) should also propagate
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("Task cancelled"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act & Assert — TaskCanceledException propagates (inherits from OperationCanceledException)
await Assert.ThrowsAsync<TaskCanceledException>(
() => strategy.CompactAsync(index).AsTask());
}
[Fact]
public async Task CompactAsyncLlmFailureWithMultipleExcludedGroupsRestoresAllAsync()
{
// Arrange — multiple groups excluded before failure, all must be restored
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Rate limited"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1,
target: _ => false); // Never stop — exclude as many as possible
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System prompt"),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — all non-system groups restored
Assert.False(result);
Assert.All(index.Groups, g => Assert.False(g.IsExcluded));
Assert.All(index.Groups, g => Assert.Null(g.ExcludeReason));
Assert.Equal(6, index.IncludedGroupCount);
}
#endregion
}
@@ -0,0 +1,438 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="ToolResultCompactionStrategy"/> class.
/// </summary>
public class ToolResultCompactionStrategyTests
{
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger requires > 1000 tokens
ToolResultCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "What's the weather?"),
toolCall,
toolResult,
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncCollapsesOldToolGroupsAsync()
{
// Arrange — always trigger
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]),
new ChatMessage(ChatRole.Tool, "Sunny and 72°F"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
// Q1 + collapsed tool summary + Q2
Assert.Equal(3, included.Count);
Assert.Equal("Q1", included[0].Text);
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny and 72°F", included[1].Text);
Assert.Equal("Q2", included[2].Text);
}
[Fact]
public async Task CompactAsyncPreservesRecentToolGroupsAsync()
{
// Arrange — protect 2 recent non-system groups (the tool group + Q2)
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 3);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
new ChatMessage(ChatRole.Tool, "Results"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — all groups are in the protected window, nothing to collapse
Assert.False(result);
}
[Fact]
public async Task CompactAsyncPreservesSystemMessagesAsync()
{
// Arrange
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]),
new ChatMessage(ChatRole.Tool, "result"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("You are helpful.", included[0].Text);
}
[Fact]
public async Task CompactAsyncExtractsMultipleToolNamesAsync()
{
// Arrange — assistant calls two tools
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1);
ChatMessage multiToolCall = new(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather"),
new FunctionCallContent("c2", "search_docs"),
]);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
multiToolCall,
new ChatMessage(ChatRole.Tool, "Sunny"),
new ChatMessage(ChatRole.Tool, "Found 3 docs"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
string collapsed = included[1].Text!;
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\nsearch_docs:\n - Found 3 docs", collapsed);
}
[Fact]
public async Task CompactAsyncNoToolGroupsReturnsFalseAsync()
{
// Arrange — trigger fires but no tool groups to collapse
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 0);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncCompoundTriggerRequiresTokensAndToolCallsAsync()
{
// Arrange — compound: tokens > 0 AND has tool calls
ToolResultCompactionStrategy strategy = new(
CompactionTriggers.All(
CompactionTriggers.TokensExceed(0),
CompactionTriggers.HasToolCalls()),
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.Tool, "result"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
}
[Fact]
public async Task CompactAsyncTargetStopsCollapsingEarlyAsync()
{
// Arrange — 2 tool groups, target met after first collapse
int collapseCount = 0;
bool TargetAfterOne(CompactionMessageIndex _) => ++collapseCount >= 1;
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1,
target: TargetAfterOne);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn1")]),
new ChatMessage(ChatRole.Tool, "result1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c2", "fn2")]),
new ChatMessage(ChatRole.Tool, "result2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — only first tool group collapsed, second left intact
Assert.True(result);
// Count collapsed tool groups (excluded with ToolCall kind)
int collapsedToolGroups = 0;
foreach (CompactionMessageGroup group in index.Groups)
{
if (group.IsExcluded && group.Kind == CompactionGroupKind.ToolCall)
{
collapsedToolGroups++;
}
}
Assert.Equal(1, collapsedToolGroups);
}
[Fact]
public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync()
{
// Arrange — pre-excluded and system groups in the enumeration
ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 0);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.System, "System prompt"),
new ChatMessage(ChatRole.User, "Q0"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.Tool, "Result 1"),
new ChatMessage(ChatRole.User, "Q1"),
];
CompactionMessageIndex index = CompactionMessageIndex.Create(messages);
// Pre-exclude the last user group
index.Groups[index.Groups.Count - 1].IsExcluded = true;
// Act
bool result = await strategy.CompactAsync(index);
// Assert — system never excluded, pre-excluded skipped
Assert.True(result);
Assert.False(index.Groups[0].IsExcluded); // System stays
}
[Fact]
public async Task CompactAsyncDeduplicatesDuplicateToolNamesAsync()
{
// Arrange — same tool called multiple times
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather"),
new FunctionCallContent("c2", "get_weather"),
]),
new ChatMessage(ChatRole.Tool, "Sunny"),
new ChatMessage(ChatRole.Tool, "Rainy"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert — duplicate names listed once with all results
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\n - Rainy", included[1].Text);
}
[Fact]
public async Task CompactAsyncIncludesResultsFromFunctionResultContentAsync()
{
// Arrange — tool results provided as FunctionResultContent (matched by CallId)
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather"),
new FunctionCallContent("c2", "search_docs"),
]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny and 72°F")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "Found 3 docs")]),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert — results matched by CallId and included in summary
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny and 72°F\nsearch_docs:\n - Found 3 docs", included[1].Text);
}
[Fact]
public async Task CompactAsyncDeduplicatesWithFunctionResultContentAsync()
{
// Arrange — same tool called multiple times with FunctionResultContent
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather"),
new FunctionCallContent("c2", "get_weather"),
new FunctionCallContent("c3", "search_docs"),
]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "Rainy")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c3", "Found 3 docs")]),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert — duplicate tool name results listed under same key
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\n - Rainy\nsearch_docs:\n - Found 3 docs", included[1].Text);
}
[Fact]
public async Task CompactAsyncUsesCustomFormatterAsync()
{
// Arrange — custom formatter that produces a collapsed message count
static string CustomFormatter(CompactionMessageGroup group) =>
$"[Collapsed: {group.Messages.Count} messages]";
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1)
{
ToolCallFormatter = CustomFormatter,
};
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
new ChatMessage(ChatRole.Tool, "Sunny"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — custom formatter output used instead of default YAML-like format
Assert.True(result);
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("[Collapsed: 2 messages]", included[1].Text);
}
[Fact]
public void ToolCallFormatterPropertyIsNullWhenNoneProvided()
{
// Arrange
ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always);
// Assert — ToolCallFormatter is null when no custom formatter is provided
Assert.Null(strategy.ToolCallFormatter);
}
[Fact]
public void ToolCallFormatterPropertyReturnsCustomFormatterWhenProvided()
{
// Arrange
Func<CompactionMessageGroup, string> customFormatter = static _ => "custom";
ToolResultCompactionStrategy strategy = new(
CompactionTriggers.Always)
{
ToolCallFormatter = customFormatter
};
// Assert — ToolCallFormatter is the injected custom function
Assert.Same(customFormatter, strategy.ToolCallFormatter);
}
[Fact]
public async Task CompactAsyncCustomFormatterCanDelegateToDefaultAsync()
{
// Arrange — custom formatter that wraps the default output
static string WrappingFormatter(CompactionMessageGroup group) =>
$"CUSTOM_PREFIX\n{ToolResultCompactionStrategy.DefaultToolCallFormatter(group)}";
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1)
{
ToolCallFormatter = WrappingFormatter
};
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.Tool, "result"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert — wrapped default output
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("CUSTOM_PREFIX\n[Tool Calls]\nfn:\n - result", included[1].Text);
}
}
@@ -0,0 +1,328 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="TruncationCompactionStrategy"/> class.
/// </summary>
public class TruncationCompactionStrategyTests
{
[Fact]
public async Task CompactAsyncAlwaysTriggerCompactsToPreserveRecentAsync()
{
// Arrange — always-trigger means always compact
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.Equal(1, groups.Groups.Count(g => !g.IsExcluded));
}
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger requires > 1000 tokens, conversation is tiny
TruncationCompactionStrategy strategy = new(
minimumPreservedGroups: 1,
trigger: CompactionTriggers.TokensExceed(1000));
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
Assert.Equal(2, groups.IncludedGroupCount);
}
[Fact]
public async Task CompactAsyncTriggerMetExcludesOldestGroupsAsync()
{
// Arrange — trigger on groups > 2
TruncationCompactionStrategy strategy = new(
minimumPreservedGroups: 1,
trigger: CompactionTriggers.GroupsExceed(2));
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
new ChatMessage(ChatRole.Assistant, "Response 2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — incremental: excludes until GroupsExceed(2) is no longer met → 2 groups remain
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
// Oldest 2 excluded, newest 2 kept
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncPreservesSystemMessagesAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// System message should be preserved
Assert.False(groups.Groups[0].IsExcluded);
Assert.Equal(CompactionGroupKind.System, groups.Groups[0].Kind);
// Oldest non-system groups excluded
Assert.True(groups.Groups[1].IsExcluded);
Assert.True(groups.Groups[2].IsExcluded);
// Most recent kept
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
ChatMessage finalResponse = new(ChatRole.User, "Thanks!");
CompactionMessageIndex groups = CompactionMessageIndex.Create([assistantToolCall, toolResult, finalResponse]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// Tool call group should be excluded as one atomic unit
Assert.True(groups.Groups[0].IsExcluded);
Assert.Equal(CompactionGroupKind.ToolCall, groups.Groups[0].Kind);
Assert.Equal(2, groups.Groups[0].Messages.Count);
Assert.False(groups.Groups[1].IsExcluded);
}
[Fact]
public async Task CompactAsyncSetsExcludeReasonAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Old"),
new ChatMessage(ChatRole.User, "New"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
Assert.NotNull(groups.Groups[0].ExcludeReason);
Assert.Contains("TruncationCompactionStrategy", groups.Groups[0].ExcludeReason);
}
[Fact]
public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Already excluded"),
new ChatMessage(ChatRole.User, "Included 1"),
new ChatMessage(ChatRole.User, "Included 2"),
]);
groups.Groups[0].IsExcluded = true;
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.True(groups.Groups[0].IsExcluded); // was already excluded
Assert.True(groups.Groups[1].IsExcluded); // newly excluded
Assert.False(groups.Groups[2].IsExcluded); // kept
}
[Fact]
public async Task CompactAsyncMinimumPreservedKeepsMultipleAsync()
{
// Arrange — keep 2 most recent
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 2);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncNothingToRemoveReturnsFalseAsync()
{
// Arrange — preserve 5 but only 2 groups
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 5);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncCustomTargetStopsEarlyAsync()
{
// Arrange — always trigger, custom target stops after 1 exclusion
int targetChecks = 0;
bool TargetAfterOne(CompactionMessageIndex _) => ++targetChecks >= 1;
TruncationCompactionStrategy strategy = new(
CompactionTriggers.Always,
minimumPreservedGroups: 1,
target: TargetAfterOne);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — only 1 group excluded (target met after first)
Assert.True(result);
Assert.True(groups.Groups[0].IsExcluded);
Assert.False(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncIncrementalStopsAtTargetAsync()
{
// Arrange — trigger on groups > 2, target is default (inverse of trigger: groups <= 2)
TruncationCompactionStrategy strategy = new(
CompactionTriggers.GroupsExceed(2),
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act — 5 groups, trigger fires (5 > 2), compacts until groups <= 2
bool result = await strategy.CompactAsync(groups);
// Assert — should stop at 2 included groups (not go all the way to 1)
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
}
[Fact]
public async Task CompactAsyncLoopExitsWhenMaxRemovableReachedAsync()
{
// Arrange — target never stops (always false), so the loop must exit via removed >= maxRemovable
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 2, target: CompactionTriggers.Never);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — only 2 removed (maxRemovable = 4 - 2 = 2), 2 preserved
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync()
{
// Arrange — has excluded + system groups that the loop must skip
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System"),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Pre-exclude one group
groups.Groups[1].IsExcluded = true;
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — system preserved, pre-excluded skipped, A1 removed, Q2 preserved
Assert.True(result);
Assert.False(groups.Groups[0].IsExcluded); // System
Assert.True(groups.Groups[1].IsExcluded); // Pre-excluded Q1
Assert.True(groups.Groups[2].IsExcluded); // Newly excluded A1
Assert.False(groups.Groups[3].IsExcluded); // Preserved Q2
}
}
@@ -0,0 +1,179 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net.Http;
using Microsoft.Agents.AI.CopilotStudio;
using Microsoft.Agents.CopilotStudio.Client;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="CopilotStudioAgent"/> class.
/// </summary>
public class CopilotStudioAgentTests
{
private static CopilotClient CreateTestCopilotClient()
{
// Create mock dependencies for CopilotClient
var mockSettings = new Mock<ConnectionSettings>();
var mockHttpClientFactory = new Mock<IHttpClientFactory>();
var mockHttpClient = new Mock<HttpClient>();
mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(mockHttpClient.Object);
return new CopilotClient(mockSettings.Object, mockHttpClientFactory.Object, NullLogger.Instance, "test-client");
}
#region GetService Method Tests
/// <summary>
/// Verify that GetService returns CopilotClient when requested.
/// </summary>
[Fact]
public void GetService_RequestingCopilotClient_ReturnsCopilotClient()
{
// Arrange
var client = CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(CopilotClient));
// Assert
Assert.NotNull(result);
Assert.Same(client, result);
}
/// <summary>
/// Verify that GetService returns AIAgentMetadata when requested.
/// </summary>
[Fact]
public void GetService_RequestingAIAgentMetadata_ReturnsMetadata()
{
// Arrange
var client = CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(AIAgentMetadata));
// Assert
Assert.NotNull(result);
Assert.IsType<AIAgentMetadata>(result);
var metadata = (AIAgentMetadata)result;
Assert.Equal("copilot-studio", metadata.ProviderName);
}
/// <summary>
/// Verify that GetService returns null for unknown service types.
/// </summary>
[Fact]
public void GetService_RequestingUnknownServiceType_ReturnsNull()
{
// Arrange
var client = CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(string));
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetService with serviceKey parameter returns null for unknown service types.
/// </summary>
[Fact]
public void GetService_WithServiceKey_ReturnsNull()
{
// Arrange
var client = CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(string), "test-key");
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetService calls base.GetService() first and returns the agent itself when requesting CopilotStudioAgent type.
/// </summary>
[Fact]
public void GetService_RequestingCopilotStudioAgentType_ReturnsBaseImplementation()
{
// Arrange
var client = CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(CopilotStudioAgent));
// Assert
Assert.NotNull(result);
Assert.Same(agent, result);
}
/// <summary>
/// Verify that GetService calls base.GetService() first and returns the agent itself when requesting AIAgent type.
/// </summary>
[Fact]
public void GetService_RequestingAIAgentType_ReturnsBaseImplementation()
{
// Arrange
var client = CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(AIAgent));
// Assert
Assert.NotNull(result);
Assert.Same(agent, result);
}
/// <summary>
/// Verify that GetService calls base.GetService() first but continues to derived logic when base returns null.
/// </summary>
[Fact]
public void GetService_RequestingCopilotClientWithServiceKey_CallsBaseFirstThenDerivedLogic()
{
// Arrange
var client = CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act - Request CopilotClient with a service key (base.GetService will return null due to serviceKey)
var result = agent.GetService(typeof(CopilotClient), "some-key");
// Assert
Assert.NotNull(result);
Assert.Same(client, result);
}
/// <summary>
/// Verify that GetService returns consistent AIAgentMetadata across multiple calls.
/// </summary>
[Fact]
public void GetService_RequestingAIAgentMetadata_ReturnsConsistentMetadata()
{
// Arrange
var client = CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result1 = agent.GetService(typeof(AIAgentMetadata));
var result2 = agent.GetService(typeof(AIAgentMetadata));
// Assert
Assert.NotNull(result1);
Assert.NotNull(result2);
Assert.Same(result1, result2); // Should return the same instance
Assert.IsType<AIAgentMetadata>(result1);
var metadata = (AIAgentMetadata)result1;
Assert.Equal("copilot-studio", metadata.ProviderName);
}
#endregion
}
@@ -0,0 +1,964 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Moq;
namespace Microsoft.Agents.AI.UnitTests.Data;
/// <summary>
/// Contains unit tests for <see cref="TextSearchProvider"/>.
/// </summary>
public sealed class TextSearchProviderTests
{
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
private readonly Mock<ILogger<TextSearchProvider>> _loggerMock;
private readonly Mock<ILoggerFactory> _loggerFactoryMock;
public TextSearchProviderTests()
{
this._loggerMock = new();
this._loggerFactoryMock = new();
this._loggerFactoryMock
.Setup(f => f.CreateLogger(It.IsAny<string>()))
.Returns(this._loggerMock.Object);
this._loggerFactoryMock
.Setup(f => f.CreateLogger(typeof(TextSearchProvider).FullName!))
.Returns(this._loggerMock.Object);
this._loggerMock
.Setup(f => f.IsEnabled(It.IsAny<LogLevel>()))
.Returns(true);
}
[Fact]
public void StateKeys_ReturnsDefaultKey_WhenNoOptionsProvided()
{
// Arrange & Act
var provider = new TextSearchProvider((_, _) => Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]));
// Assert
Assert.Single(provider.StateKeys);
Assert.Contains("TextSearchProvider", provider.StateKeys);
}
[Fact]
public void StateKeys_ReturnsCustomKey_WhenSetViaOptions()
{
// Arrange & Act
var provider = new TextSearchProvider(
(_, _) => Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]),
new TextSearchProviderOptions { StateKey = "custom-key" });
// Assert
Assert.Single(provider.StateKeys);
Assert.Contains("custom-key", provider.StateKeys);
}
[Theory]
[InlineData(null, null, true)]
[InlineData("Custom context prompt", "Custom citations prompt", false)]
public async Task InvokingAsync_ShouldInjectFormattedResultsAsync(string? overrideContextPrompt, string? overrideCitationsPrompt, bool withLogging)
{
// Arrange
List<TextSearchProvider.TextSearchResult> results =
[
new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" },
new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" }
];
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
}
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
ContextPrompt = overrideContextPrompt,
CitationsPrompt = overrideCitationsPrompt,
EnableSensitiveTelemetryData = true
};
var provider = new TextSearchProvider(SearchDelegateAsync, options, withLogging ? this._loggerFactoryMock.Object : null);
var invokingContext = new AIContextProvider.InvokingContext(
s_mockAgent,
new TestAgentSession(),
new AIContext
{
Messages = new List<ChatMessage>
{
new(ChatRole.User, "Sample user question?"),
new(ChatRole.User, "Additional part")
}
});
// Act
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.Equal("Sample user question?\nAdditional part", capturedInput);
Assert.Null(aiContext.Instructions); // TextSearchProvider uses a user message for context injection.
Assert.NotNull(aiContext.Messages);
var messages = aiContext.Messages!.ToList();
Assert.Equal(3, messages.Count); // 2 input messages + 1 search result message
Assert.Equal("Sample user question?", messages[0].Text);
Assert.Equal("Additional part", messages[1].Text);
Assert.Equal(AgentRequestMessageSourceType.External, messages[0].GetAgentRequestMessageSourceType());
Assert.Equal(AgentRequestMessageSourceType.External, messages[1].GetAgentRequestMessageSourceType());
var message = messages.Last();
Assert.Equal(ChatRole.User, message.Role);
Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, message.GetAgentRequestMessageSourceType());
string text = message.Text!;
if (overrideContextPrompt is null)
{
Assert.Contains("## Additional Context", text);
Assert.Contains("Consider the following information from source documents when responding to the user:", text);
}
else
{
Assert.Contains(overrideContextPrompt, text);
}
Assert.Contains("SourceDocName: Doc1", text);
Assert.Contains("SourceDocLink: http://example.com/doc1", text);
Assert.Contains("Contents: Content of Doc1", text);
Assert.Contains("SourceDocName: Doc2", text);
Assert.Contains("SourceDocLink: http://example.com/doc2", text);
Assert.Contains("Contents: Content of Doc2", text);
if (overrideCitationsPrompt is null)
{
Assert.Contains("Include citations to the source document with document name and link if document name and link is available.", text);
}
else
{
Assert.Contains(overrideCitationsPrompt, text);
}
if (withLogging)
{
this._loggerMock.Verify(
l => l.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("TextSearchProvider: Retrieved 2 search results.")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
this._loggerMock.Verify(
l => l.Log(
LogLevel.Trace,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("TextSearchProvider: Search Results\nInput:Sample user question?\nAdditional part\nOutput")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task InvokingAsync_RedactsLogDataBasedOnOptionsAsync(bool enableSensitiveTelemetryData, bool useCustomRedactor)
{
// Arrange
List<TextSearchProvider.TextSearchResult> results =
[
new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" }
];
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
}
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
EnableSensitiveTelemetryData = enableSensitiveTelemetryData,
Redactor = useCustomRedactor ? new ReplacingRedactor("***") : null
};
var provider = new TextSearchProvider(SearchDelegateAsync, options, this._loggerFactoryMock.Object);
var invokingContext = new AIContextProvider.InvokingContext(
s_mockAgent,
new TestAgentSession(),
new AIContext { Messages = new List<ChatMessage> { new(ChatRole.User, "Sample user question?") } });
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert — EnableSensitiveTelemetryData takes precedence over Redactor
var traceInvocation = this._loggerMock.Invocations
.Where(i => i.Method.Name == nameof(ILogger.Log))
.FirstOrDefault(i => (LogLevel)i.Arguments[0]! == LogLevel.Trace);
Assert.NotNull(traceInvocation);
var state = Assert.IsType<IReadOnlyList<KeyValuePair<string, object?>>>(traceInvocation.Arguments[2], exactMatch: false);
var inputValue = state.First(kvp => kvp.Key == "Input").Value;
var messageTextValue = state.First(kvp => kvp.Key == "MessageText").Value;
if (enableSensitiveTelemetryData)
{
// EnableSensitiveTelemetryData=true: raw data passes through regardless of Redactor
Assert.Equal("Sample user question?", inputValue);
Assert.Contains("Content of Doc1", messageTextValue?.ToString()!);
}
else
{
// EnableSensitiveTelemetryData=false: custom redactor or default placeholder
string expectedRedaction = useCustomRedactor ? "***" : "<redacted>";
Assert.Equal(expectedRedaction, inputValue);
Assert.Equal(expectedRedaction, messageTextValue);
}
}
[Theory]
[InlineData(null, null, "Search", "Allows searching for additional information to help answer the user question.")]
[InlineData("CustomSearch", "CustomDescription", "CustomSearch", "CustomDescription")]
public async Task InvokingAsync_OnDemand_ShouldExposeSearchToolAsync(string? overrideName, string? overrideDescription, string expectedName, string expectedDescription)
{
// Arrange
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.OnDemandFunctionCalling,
FunctionToolName = overrideName,
FunctionToolDescription = overrideDescription
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List<ChatMessage> { new(ChatRole.User, "Q?") } });
// Act
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.NotNull(aiContext.Messages); // Input messages are preserved.
var messages = aiContext.Messages!.ToList();
Assert.Single(messages);
Assert.Equal("Q?", messages[0].Text);
Assert.NotNull(aiContext.Tools);
var tools = aiContext.Tools!.ToList();
Assert.Single(tools);
var tool = tools[0];
Assert.Equal(expectedName, tool.Name);
Assert.Equal(expectedDescription, tool.Description);
}
[Fact]
public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync()
{
// Arrange
var provider = new TextSearchProvider(this.FailingSearchAsync, loggerFactory: this._loggerFactoryMock.Object);
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List<ChatMessage> { new(ChatRole.User, "Q?") } });
// Act
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.NotNull(aiContext.Messages); // Input messages are preserved on error.
var messages = aiContext.Messages!.ToList();
Assert.Single(messages);
Assert.Equal("Q?", messages[0].Text);
Assert.Null(aiContext.Tools);
this._loggerMock.Verify(
l => l.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("TextSearchProvider: Failed to search for data due to error")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
[Theory]
[InlineData(null, null)]
[InlineData("Custom context prompt", "Custom citations prompt")]
public async Task SearchAsync_ShouldReturnFormattedResultsAsync(string? overrideContextPrompt, string? overrideCitationsPrompt)
{
// Arrange
List<TextSearchProvider.TextSearchResult> results =
[
new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" },
new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" }
];
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
}
var options = new TextSearchProviderOptions
{
ContextPrompt = overrideContextPrompt,
CitationsPrompt = overrideCitationsPrompt
};
var provider = new TextSearchProvider(SearchDelegateAsync, options);
// Act
var formatted = await provider.SearchAsync("Sample user question?", CancellationToken.None);
// Assert
if (overrideContextPrompt is null)
{
Assert.Contains("## Additional Context", formatted);
Assert.Contains("Consider the following information from source documents when responding to the user:", formatted);
}
else
{
Assert.Contains(overrideContextPrompt, formatted);
}
Assert.Contains("SourceDocName: Doc1", formatted);
Assert.Contains("SourceDocLink: http://example.com/doc1", formatted);
Assert.Contains("Contents: Content of Doc1", formatted);
Assert.Contains("SourceDocName: Doc2", formatted);
Assert.Contains("SourceDocLink: http://example.com/doc2", formatted);
Assert.Contains("Contents: Content of Doc2", formatted);
if (overrideCitationsPrompt is null)
{
Assert.Contains("Include citations to the source document with document name and link if document name and link is available.", formatted);
}
else
{
Assert.Contains(overrideCitationsPrompt, formatted);
}
}
[Fact]
public async Task InvokingAsync_ShouldUseContextFormatterWhenProvidedAsync()
{
// Arrange
List<TextSearchProvider.TextSearchResult> results =
[
new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" },
new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" }
];
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
}
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
ContextFormatter = r => $"Custom formatted context with {r.Count} results."
};
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List<ChatMessage> { new(ChatRole.User, "Q?") } });
// Act
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.NotNull(aiContext.Messages);
var messages = aiContext.Messages!.ToList();
Assert.Equal(2, messages.Count); // 1 input message + 1 formatted result message
Assert.Equal("Q?", messages[0].Text);
Assert.Equal("Custom formatted context with 2 results.", messages[1].Text);
}
[Fact]
public async Task InvokingAsync_WithRawRepresentations_ContextFormatterCanAccessAsync()
{
// Arrange
var payload1 = new RawPayload { Id = "R1" };
var payload2 = new RawPayload { Id = "R2" };
List<TextSearchProvider.TextSearchResult> results =
[
new() { SourceName = "Doc1", Text = "Content 1", RawRepresentation = payload1 },
new() { SourceName = "Doc2", Text = "Content 2", RawRepresentation = payload2 }
];
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
}
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
ContextFormatter = r => string.Join(",", r.Select(x => ((RawPayload)x.RawRepresentation!).Id))
};
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List<ChatMessage> { new(ChatRole.User, "Q?") } });
// Act
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.NotNull(aiContext.Messages);
var messages = aiContext.Messages!.ToList();
Assert.Equal(2, messages.Count); // 1 input message + 1 formatted result message
Assert.Equal("Q?", messages[0].Text);
Assert.Equal("R1,R2", messages[1].Text);
}
[Fact]
public async Task InvokingAsync_WithNoResults_ShouldReturnEmptyContextAsync()
{
// Arrange
var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke };
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List<ChatMessage> { new(ChatRole.User, "Q?") } });
// Act
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.NotNull(aiContext.Messages); // Input messages are preserved when no results found.
var messages = aiContext.Messages!.ToList();
Assert.Single(messages);
Assert.Equal("Q?", messages[0].Text);
Assert.Null(aiContext.Instructions);
Assert.Null(aiContext.Tools);
}
#region Message Filter Tests
[Fact]
public async Task InvokingAsync_DefaultFilter_ExcludesNonExternalMessagesFromSearchInputAsync()
{
// Arrange
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
var provider = new TextSearchProvider(SearchDelegateAsync);
var requestMessages = new List<ChatMessage>
{
new(ChatRole.User, "External message"),
new(ChatRole.System, "From history") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "HistorySource") } } },
new(ChatRole.System, "From context provider") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "ContextSource") } } },
};
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = requestMessages });
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert - Only external messages should be used for search input
Assert.Equal("External message", capturedInput);
}
[Fact]
public async Task InvokingAsync_CustomSearchInputFilter_OverridesDefaultAsync()
{
// Arrange
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
var provider = new TextSearchProvider(SearchDelegateAsync, new TextSearchProviderOptions
{
SearchInputMessageFilter = messages => messages.Where(m => m.Role == ChatRole.System)
});
var requestMessages = new List<ChatMessage>
{
new(ChatRole.User, "User message"),
new(ChatRole.System, "System message"),
};
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = requestMessages });
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert - Custom filter keeps only System messages
Assert.Equal("System message", capturedInput);
}
[Fact]
public async Task InvokedAsync_DefaultFilter_ExcludesNonExternalMessagesFromStorageAsync()
{
// Arrange
var options = new TextSearchProviderOptions
{
RecentMessageMemoryLimit = 10,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.System]
};
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var session = new TestAgentSession();
var requestMessages = new List<ChatMessage>
{
new(ChatRole.User, "External message"),
new(ChatRole.System, "From history") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "HistorySource") } } },
new(ChatRole.System, "From context provider") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "ContextSource") } } },
};
// Store messages via InvokedAsync
await provider.InvokedAsync(new(s_mockAgent, session, requestMessages, []));
// Now invoke to read stored memory
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, session, new AIContext { Messages = [new ChatMessage(ChatRole.User, "Next")] });
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert - Only "External message" was stored in memory, so search input = "External message" + "Next"
Assert.Equal("External message\nNext", capturedInput);
}
[Fact]
public async Task InvokedAsync_CustomStorageInputFilter_OverridesDefaultAsync()
{
// Arrange
var options = new TextSearchProviderOptions
{
RecentMessageMemoryLimit = 10,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.System],
StorageInputRequestMessageFilter = messages => messages // No filtering - store everything
};
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var session = new TestAgentSession();
var requestMessages = new List<ChatMessage>
{
new(ChatRole.User, "External message"),
new(ChatRole.System, "From history") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "HistorySource") } } },
};
// Store messages via InvokedAsync
await provider.InvokedAsync(new(s_mockAgent, session, requestMessages, []));
// Now invoke to read stored memory
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, session, new AIContext { Messages = [new ChatMessage(ChatRole.User, "Next")] });
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert - Both messages stored (identity filter), so search input includes all + current
Assert.Equal("External message\nFrom history\nNext", capturedInput);
}
#endregion
#region Recent Message Memory Tests
[Fact]
public async Task InvokingAsync_WithPreviousFailedRequest_ShouldNotIncludeFailedRequestInputInSearchInputAsync()
{
// Arrange
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 3
};
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed.
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
// Populate memory with more messages than the limit (A,B,C,D) -> should retain B,C,D
var initialMessages = new[]
{
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.Assistant, "B"),
new ChatMessage(ChatRole.User, "C"),
new ChatMessage(ChatRole.Assistant, "D"),
};
var session = new TestAgentSession();
await provider.InvokedAsync(new(s_mockAgent, session, initialMessages, new InvalidOperationException("Request Failed")));
var invokingContext = new AIContextProvider.InvokingContext(
s_mockAgent,
session,
new AIContext { Messages = new List<ChatMessage> { new(ChatRole.User, "E") } });
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.Equal("E", capturedInput); // Only the messages from the current request, since previous failed request should not be stored.
}
[Fact]
public async Task InvokingAsync_WithRecentMessageMemory_ShouldIncludeStoredMessagesInSearchInputAsync()
{
// Arrange
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 3,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
};
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed.
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var session = new TestAgentSession();
// Populate memory with more messages than the limit (A,B,C,D) -> should retain B,C,D
var initialMessages = new[]
{
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.Assistant, "B"),
new ChatMessage(ChatRole.User, "C"),
new ChatMessage(ChatRole.Assistant, "D"),
};
await provider.InvokedAsync(new(s_mockAgent, session, initialMessages, []));
var invokingContext = new AIContextProvider.InvokingContext(
s_mockAgent,
session,
new AIContext { Messages = new List<ChatMessage> { new(ChatRole.User, "E") } });
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.Equal("B\nC\nD\nE", capturedInput); // Memory first (truncated) then current request.
}
[Fact]
public async Task InvokingAsync_WithAccumulatedMemoryAcrossInvocations_ShouldIncludeAllUpToLimitAsync()
{
// Arrange
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 5,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
};
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var session = new TestAgentSession();
// First memory update (A,B)
await provider.InvokedAsync(new(
s_mockAgent,
session,
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.Assistant, "B"),
],
[]));
// Second memory update (C,D,E)
await provider.InvokedAsync(new(
s_mockAgent,
session,
[
new ChatMessage(ChatRole.User, "C"),
new ChatMessage(ChatRole.Assistant, "D"),
new ChatMessage(ChatRole.User, "E"),
],
[]));
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, session, new AIContext { Messages = new List<ChatMessage> { new(ChatRole.User, "F") } });
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.Equal("A\nB\nC\nD\nE\nF", capturedInput); // All retained (limit 5) + current request message.
}
[Fact]
public async Task InvokingAsync_WithRecentMessageRolesIncluded_ShouldFilterRolesAsync()
{
// Arrange
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 4,
RecentMessageRolesIncluded = [ChatRole.Assistant] // Only retain assistant messages.
};
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed for this test.
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var session = new TestAgentSession();
// Populate memory with mixed roles; only Assistant messages (A1,A2) should be retained.
var initialMessages = new[]
{
new ChatMessage(ChatRole.User, "U1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "U2"),
new ChatMessage(ChatRole.Assistant, "A2"),
};
await provider.InvokedAsync(new(s_mockAgent, session, initialMessages, []));
var invokingContext = new AIContextProvider.InvokingContext(
s_mockAgent,
session,
new AIContext { Messages = new List<ChatMessage> { new(ChatRole.User, "Question?") } }); // Current request message always appended.
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.Equal("A1\nA2\nQuestion?", capturedInput); // Only assistant messages from memory + current request.
}
#endregion
#region Serialization Tests
[Fact]
public async Task InvokedAsync_ShouldPersistMessagesToSessionStateBagAsync()
{
// Arrange
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 3,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var session = new TestAgentSession();
var messages = new[]
{
new ChatMessage(ChatRole.User, "M1"),
new ChatMessage(ChatRole.Assistant, "M2"),
new ChatMessage(ChatRole.User, "M3"),
};
// Act
await provider.InvokedAsync(new(s_mockAgent, session, messages, [])); // Populate recent memory.
// Assert - State should be in the session's StateBag
var stateBagSerialized = session.StateBag.Serialize();
Assert.True(stateBagSerialized.TryGetProperty("TextSearchProvider", out var stateProperty));
Assert.True(stateProperty.TryGetProperty("recentMessagesText", out var recentProperty));
Assert.Equal(JsonValueKind.Array, recentProperty.ValueKind);
var list = recentProperty.EnumerateArray().Select(e => e.GetString()).ToList();
Assert.Equal(3, list.Count);
Assert.Equal(["M1", "M2", "M3"], list);
}
[Fact]
public async Task StateBag_RoundtripRestoresMessagesAsync()
{
// Arrange
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 4,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var session = new TestAgentSession();
var messages = new[]
{
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.Assistant, "B"),
new ChatMessage(ChatRole.User, "C"),
new ChatMessage(ChatRole.Assistant, "D"),
};
await provider.InvokedAsync(new(s_mockAgent, session, messages, []));
// Act - Serialize and deserialize the StateBag
var serializedStateBag = session.StateBag.Serialize();
var restoredSession = new TestAgentSession(AgentSessionStateBag.Deserialize(serializedStateBag));
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegate2Async(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
var newProvider = new TextSearchProvider(SearchDelegate2Async, new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 4
});
await newProvider.InvokingAsync(new AIContextProvider.InvokingContext(s_mockAgent, restoredSession, new AIContext()), CancellationToken.None); // Trigger search to read memory.
// Assert
Assert.NotNull(capturedInput);
Assert.Equal("A\nB\nC\nD", capturedInput);
}
[Fact]
public async Task InvokingAsync_WithEmptyStateBag_ShouldHaveNoMessagesAsync()
{
// Arrange
var session = new TestAgentSession(); // Fresh session with empty StateBag
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegate2Async(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
// Act
var provider = new TextSearchProvider(SearchDelegate2Async, new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 3
});
await provider.InvokingAsync(new AIContextProvider.InvokingContext(s_mockAgent, session, new AIContext()), CancellationToken.None);
// Assert
Assert.NotNull(capturedInput);
Assert.Equal(string.Empty, capturedInput); // No recent messages in StateBag => empty input.
}
#endregion
#region MessageAIContextProvider.InvokingAsync Tests
[Fact]
public async Task MessageInvokingAsync_BeforeAIInvoke_SearchesAndReturnsMergedMessagesAsync()
{
// Arrange
List<TextSearchProvider.TextSearchResult> results =
[
new() { SourceName = "Doc1", Text = "Content of Doc1" }
];
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
=> Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
var provider = new TextSearchProvider(SearchDelegateAsync, new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke
});
var inputMsg = new ChatMessage(ChatRole.User, "Question?");
var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [inputMsg]);
// Act
var messages = (await provider.InvokingAsync(context)).ToList();
// Assert - input message + search result message, with stamping
Assert.Equal(2, messages.Count);
Assert.Equal("Question?", messages[0].Text);
Assert.Contains("Content of Doc1", messages[1].Text);
Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, messages[1].GetAgentRequestMessageSourceType());
}
[Fact]
public async Task MessageInvokingAsync_OnDemand_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
var provider = new TextSearchProvider(this.NoResultSearchAsync, new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.OnDemandFunctionCalling,
});
var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [new ChatMessage(ChatRole.User, "Q?")]);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => provider.InvokingAsync(context).AsTask());
}
[Fact]
public async Task MessageInvokingAsync_BeforeAIInvoke_NoResults_ReturnsOnlyInputMessagesAsync()
{
// Arrange
var provider = new TextSearchProvider(this.NoResultSearchAsync, new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke
});
var inputMsg = new ChatMessage(ChatRole.User, "Hello");
var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [inputMsg]);
// Act
var messages = (await provider.InvokingAsync(context)).ToList();
// Assert
Assert.Single(messages);
Assert.Equal("Hello", messages[0].Text);
}
[Fact]
public async Task MessageInvokingAsync_BeforeAIInvoke_DefaultFilter_ExcludesNonExternalMessagesAsync()
{
// Arrange
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
var provider = new TextSearchProvider(SearchDelegateAsync, new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke
});
var externalMsg = new ChatMessage(ChatRole.User, "External message");
var historyMsg = new ChatMessage(ChatRole.System, "From history")
.WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, "src");
var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [externalMsg, historyMsg]);
// Act
await provider.InvokingAsync(context);
// Assert - Only External message used for search query
Assert.Equal("External message", capturedInput);
}
#endregion
private Task<IEnumerable<TextSearchProvider.TextSearchResult>> NoResultSearchAsync(string input, CancellationToken ct)
{
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
private Task<IEnumerable<TextSearchProvider.TextSearchResult>> FailingSearchAsync(string input, CancellationToken ct)
{
throw new InvalidOperationException("Search Failed");
}
private sealed class RawPayload
{
public string Id { get; set; } = string.Empty;
}
private sealed class TestAgentSession : AgentSession
{
public TestAgentSession()
{
}
public TestAgentSession(AgentSessionStateBag stateBag)
{
this.StateBag = stateBag;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,665 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AgentModeProvider"/> class.
/// </summary>
public class AgentModeProviderTests
{
#region ProvideAIContextAsync Tests
/// <summary>
/// Verify that the provider returns tools and instructions.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_ReturnsToolsAndInstructionsAsync()
{
// Arrange
var provider = new AgentModeProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.NotNull(result.Instructions);
Assert.NotNull(result.Tools);
Assert.Equal(2, result.Tools!.Count());
}
/// <summary>
/// Verify that the instructions include the current mode.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_InstructionsIncludeCurrentModeAsync()
{
// Arrange
var provider = new AgentModeProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.Contains("plan", result.Instructions);
}
#endregion
#region SetMode Tool Tests
/// <summary>
/// Verify that SetMode changes the mode.
/// </summary>
[Fact]
public async Task SetMode_ChangesModeAsync()
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction setMode = GetTool(tools, "mode_set");
// Act
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
// Assert
Assert.Equal("execute", state.CurrentMode);
}
/// <summary>
/// Verify that SetMode returns a confirmation message.
/// </summary>
[Fact]
public async Task SetMode_ReturnsConfirmationAsync()
{
// Arrange
var (tools, _) = await CreateToolsWithStateAsync();
AIFunction setMode = GetTool(tools, "mode_set");
// Act
object? result = await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
// Assert
Assert.Equal("Mode changed to \"execute\".", GetStringResult(result));
}
/// <summary>
/// Verify that SetMode with an unsupported value throws and does not persist the mode.
/// </summary>
[Fact]
public async Task SetMode_InvalidMode_ThrowsAsync()
{
// Arrange
var (tools, provider, session) = await CreateToolsWithProviderAndSessionAsync();
AIFunction setMode = GetTool(tools, "mode_set");
AIFunction getMode = GetTool(tools, "mode_get");
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(async () =>
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "foo" }));
// Verify mode was not changed from default
object? currentMode = await getMode.InvokeAsync(new AIFunctionArguments());
Assert.Equal("plan", GetStringResult(currentMode));
}
#endregion
#region GetMode Tool Tests
/// <summary>
/// Verify that GetMode returns the default mode.
/// </summary>
[Fact]
public async Task GetMode_ReturnsDefaultModeAsync()
{
// Arrange
var (tools, _) = await CreateToolsWithStateAsync();
AIFunction getMode = GetTool(tools, "mode_get");
// Act
object? result = await getMode.InvokeAsync(new AIFunctionArguments());
// Assert
Assert.Equal("plan", GetStringResult(result));
}
/// <summary>
/// Verify that GetMode returns the mode after SetMode.
/// </summary>
[Fact]
public async Task GetMode_ReturnsUpdatedModeAfterSetAsync()
{
// Arrange
var (tools, _) = await CreateToolsWithStateAsync();
AIFunction setMode = GetTool(tools, "mode_set");
AIFunction getMode = GetTool(tools, "mode_get");
// Act
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
object? result = await getMode.InvokeAsync(new AIFunctionArguments());
// Assert
Assert.Equal("execute", GetStringResult(result));
}
#endregion
#region Public Helper Method Tests
/// <summary>
/// Verify that the public GetMode helper returns the default mode.
/// </summary>
[Fact]
public void PublicGetMode_ReturnsDefaultMode()
{
// Arrange
var provider = new AgentModeProvider();
var session = new ChatClientAgentSession();
// Act
string mode = provider.GetMode(session);
// Assert
Assert.Equal("plan", mode);
}
/// <summary>
/// Verify that the public SetMode helper changes the mode.
/// </summary>
[Fact]
public void PublicSetMode_ChangesMode()
{
// Arrange
var provider = new AgentModeProvider();
var session = new ChatClientAgentSession();
// Act
provider.SetMode(session, "execute");
string mode = provider.GetMode(session);
// Assert
Assert.Equal("execute", mode);
}
/// <summary>
/// Verify that the public SetMode helper throws for an unsupported value and does not persist the mode.
/// </summary>
[Fact]
public void PublicSetMode_InvalidMode_Throws()
{
// Arrange
var provider = new AgentModeProvider();
var session = new ChatClientAgentSession();
// Act & Assert
Assert.Throws<ArgumentException>(() => provider.SetMode(session, "foo"));
// Verify mode was not changed from default
string mode = provider.GetMode(session);
Assert.Equal("plan", mode);
}
/// <summary>
/// Verify that public helper changes are reflected in tool results.
/// </summary>
[Fact]
public async Task PublicSetMode_ReflectedInToolResultsAsync()
{
// Arrange
var provider = new AgentModeProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
// Set mode via public helper
provider.SetMode(session, "execute");
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
AIFunction getMode = GetTool(result.Tools!, "mode_get");
object? modeResult = await getMode.InvokeAsync(new AIFunctionArguments());
// Assert
Assert.Equal("execute", GetStringResult(modeResult));
Assert.Contains("execute", result.Instructions);
}
#endregion
#region State Persistence Tests
/// <summary>
/// Verify that state persists across invocations.
/// </summary>
[Fact]
public async Task State_PersistsAcrossInvocationsAsync()
{
// Arrange
var provider = new AgentModeProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act — first invocation changes mode
AIContext result1 = await provider.InvokingAsync(context);
AIFunction setMode = GetTool(result1.Tools!, "mode_set");
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
// Second invocation should see the updated mode
AIContext result2 = await provider.InvokingAsync(context);
AIFunction getMode = GetTool(result2.Tools!, "mode_get");
object? modeResult = await getMode.InvokeAsync(new AIFunctionArguments());
// Assert
Assert.Equal("execute", GetStringResult(modeResult));
Assert.Contains("execute", result2.Instructions);
}
#endregion
#region Options Tests
/// <summary>
/// Verify that custom instructions override the default.
/// </summary>
[Fact]
public async Task Options_CustomInstructions_OverridesDefaultAsync()
{
// Arrange
var options = new AgentModeProviderOptions { Instructions = "Custom mode instructions." };
var provider = new AgentModeProvider(options);
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.Equal("Custom mode instructions.", result.Instructions);
}
/// <summary>
/// Verify that custom modes are used.
/// </summary>
[Fact]
public void Options_CustomModes_AreUsed()
{
// Arrange
var options = new AgentModeProviderOptions
{
Modes =
[
new AgentModeProviderOptions.AgentMode("draft", "Drafting mode."),
new AgentModeProviderOptions.AgentMode("review", "Review mode."),
],
};
var provider = new AgentModeProvider(options);
var session = new ChatClientAgentSession();
// Act
string mode = provider.GetMode(session);
// Assert — default mode is first in list
Assert.Equal("draft", mode);
}
/// <summary>
/// Verify that SetMode validates against custom modes.
/// </summary>
[Fact]
public void Options_CustomModes_SetModeValidatesAgainstList()
{
// Arrange
var options = new AgentModeProviderOptions
{
Modes =
[
new AgentModeProviderOptions.AgentMode("draft", "Drafting mode."),
new AgentModeProviderOptions.AgentMode("review", "Review mode."),
],
};
var provider = new AgentModeProvider(options);
var session = new ChatClientAgentSession();
// Act — valid mode
provider.SetMode(session, "review");
// Assert
Assert.Equal("review", provider.GetMode(session));
// Act & Assert — invalid mode (plan is no longer valid)
Assert.Throws<ArgumentException>(() => provider.SetMode(session, "plan"));
}
/// <summary>
/// Verify that a custom default mode is used.
/// </summary>
[Fact]
public void Options_CustomDefaultMode_IsUsed()
{
// Arrange
var options = new AgentModeProviderOptions
{
Modes =
[
new AgentModeProviderOptions.AgentMode("draft", "Drafting mode."),
new AgentModeProviderOptions.AgentMode("review", "Review mode."),
],
DefaultMode = "review",
};
var provider = new AgentModeProvider(options);
var session = new ChatClientAgentSession();
// Act
string mode = provider.GetMode(session);
// Assert
Assert.Equal("review", mode);
}
/// <summary>
/// Verify that an invalid default mode throws.
/// </summary>
[Fact]
public void Options_InvalidDefaultMode_Throws()
{
// Arrange
var options = new AgentModeProviderOptions
{
Modes =
[
new AgentModeProviderOptions.AgentMode("draft", "Drafting mode."),
],
DefaultMode = "nonexistent",
};
// Act & Assert
Assert.Throws<ArgumentException>(() => new AgentModeProvider(options));
}
/// <summary>
/// Verify that an empty modes list throws.
/// </summary>
[Fact]
public void Options_EmptyModes_Throws()
{
// Arrange
var options = new AgentModeProviderOptions
{
Modes = [],
};
// Act & Assert
Assert.Throws<ArgumentException>(() => new AgentModeProvider(options));
}
/// <summary>
/// Verify that custom modes appear in generated instructions.
/// </summary>
[Fact]
public async Task Options_CustomModes_AppearInInstructionsAsync()
{
// Arrange
var options = new AgentModeProviderOptions
{
Modes =
[
new AgentModeProviderOptions.AgentMode("draft", "Drafting mode description."),
new AgentModeProviderOptions.AgentMode("review", "Review mode description."),
],
};
var provider = new AgentModeProvider(options);
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.Contains("draft", result.Instructions);
Assert.Contains("Drafting mode description.", result.Instructions);
Assert.Contains("review", result.Instructions);
Assert.Contains("Review mode description.", result.Instructions);
}
/// <summary>
/// Verify that AgentMode requires non-empty name and description.
/// </summary>
[Fact]
public void AgentMode_RequiresNameAndDescription()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => new AgentModeProviderOptions.AgentMode("", "desc"));
Assert.Throws<ArgumentException>(() => new AgentModeProviderOptions.AgentMode("name", ""));
Assert.ThrowsAny<ArgumentException>(() => new AgentModeProviderOptions.AgentMode(null!, "desc"));
Assert.ThrowsAny<ArgumentException>(() => new AgentModeProviderOptions.AgentMode("name", null!));
}
/// <summary>
/// Verify that duplicate mode names throw.
/// </summary>
[Fact]
public void Options_DuplicateModeNames_Throws()
{
// Arrange
var options = new AgentModeProviderOptions
{
Modes =
[
new AgentModeProviderOptions.AgentMode("draft", "First draft."),
new AgentModeProviderOptions.AgentMode("draft", "Second draft."),
],
};
// Act & Assert
var ex = Assert.Throws<ArgumentException>(() => new AgentModeProvider(options));
Assert.Contains("duplicate", ex.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Verify that a null entry in the modes list throws.
/// </summary>
[Fact]
public void Options_NullModeEntry_Throws()
{
// Arrange
var options = new AgentModeProviderOptions
{
Modes = new List<AgentModeProviderOptions.AgentMode> { null! },
};
// Act & Assert
var ex = Assert.Throws<ArgumentException>(() => new AgentModeProvider(options));
Assert.Contains("must not be null", ex.Message, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region External Mode Change Notification Tests
/// <summary>
/// Verify that an external mode change injects a notification message.
/// </summary>
[Fact]
public async Task ExternalModeChange_InjectsNotificationMessageAsync()
{
// Arrange
var provider = new AgentModeProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
// Change mode externally (simulating /mode command)
provider.SetMode(session, "execute");
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.NotNull(result.Messages);
Assert.Single(result.Messages!);
ChatMessage message = result.Messages!.First();
Assert.Equal(ChatRole.User, message.Role);
Assert.Contains("plan", message.Text);
Assert.Contains("execute", message.Text);
}
/// <summary>
/// Verify that the notification is only injected once (cleared after first read).
/// </summary>
[Fact]
public async Task ExternalModeChange_NotificationClearedAfterFirstReadAsync()
{
// Arrange
var provider = new AgentModeProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
provider.SetMode(session, "execute");
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act — first call should have the notification
AIContext result1 = await provider.InvokingAsync(context);
Assert.NotNull(result1.Messages);
// Second call should NOT have the notification
AIContext result2 = await provider.InvokingAsync(context);
// Assert
Assert.Null(result2.Messages);
}
/// <summary>
/// Verify that tool-based mode change does not inject a notification message.
/// </summary>
[Fact]
public async Task ToolModeChange_DoesNotInjectNotificationAsync()
{
// Arrange
var provider = new AgentModeProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// First call to initialize
AIContext result1 = await provider.InvokingAsync(context);
AIFunction setMode = GetTool(result1.Tools!, "mode_set");
// Change mode via the tool (agent-initiated)
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
// Act — next call should NOT have a notification
AIContext result2 = await provider.InvokingAsync(context);
// Assert
Assert.Null(result2.Messages);
}
/// <summary>
/// Verify that setting the same mode externally does not inject a notification.
/// </summary>
[Fact]
public async Task ExternalModeChange_SameMode_NoNotificationAsync()
{
// Arrange
var provider = new AgentModeProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
// Set to same default mode
provider.SetMode(session, "plan");
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.Null(result.Messages);
}
#endregion
#region Helper Methods
private static async Task<(IEnumerable<AITool> Tools, AgentModeState State)> CreateToolsWithStateAsync()
{
var provider = new AgentModeProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
// Retrieve the state from the session to verify mutations
session.StateBag.TryGetValue<AgentModeState>("AgentModeProvider", out var state, AgentJsonUtilities.DefaultOptions);
return (result.Tools!, state!);
}
private static async Task<(IEnumerable<AITool> Tools, AgentModeProvider Provider, AgentSession Session)> CreateToolsWithProviderAndSessionAsync()
{
var provider = new AgentModeProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
return (result.Tools!, provider, session);
}
private static AIFunction GetTool(IEnumerable<AITool> tools, string name)
{
return (AIFunction)tools.First(t => t is AIFunction f && f.Name == name);
}
private static string GetStringResult(object? result)
{
var element = Assert.IsType<JsonElement>(result);
return element.GetString()!;
}
#endregion
}
@@ -0,0 +1,968 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
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="BackgroundAgentsProvider"/> class.
/// </summary>
public class BackgroundAgentsProviderTests
{
#region Constructor Tests
/// <summary>
/// Verify that the constructor throws when agents is null.
/// </summary>
[Fact]
public void Constructor_NullAgents_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new BackgroundAgentsProvider(null!));
}
/// <summary>
/// Verify that the constructor throws when agents collection is empty.
/// </summary>
[Fact]
public void Constructor_EmptyAgents_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => new BackgroundAgentsProvider(Array.Empty<AIAgent>()));
}
/// <summary>
/// Verify that the constructor throws when an agent has a null name.
/// </summary>
[Fact]
public void Constructor_AgentWithNullName_Throws()
{
// Arrange
var agent = CreateMockAgent(null!, "desc");
// Act & Assert
Assert.Throws<ArgumentException>(() => new BackgroundAgentsProvider(new[] { agent }));
}
/// <summary>
/// Verify that the constructor throws when an agent has an empty name.
/// </summary>
[Fact]
public void Constructor_AgentWithEmptyName_Throws()
{
// Arrange
var agent = CreateMockAgent("", "desc");
// Act & Assert
Assert.Throws<ArgumentException>(() => new BackgroundAgentsProvider(new[] { agent }));
}
/// <summary>
/// Verify that the constructor throws when duplicate agent names are provided (case-insensitive).
/// </summary>
[Fact]
public void Constructor_DuplicateNames_Throws()
{
// Arrange
var agent1 = CreateMockAgent("Research", "Agent 1");
var agent2 = CreateMockAgent("research", "Agent 2");
// Act & Assert
Assert.Throws<ArgumentException>(() => new BackgroundAgentsProvider(new[] { agent1, agent2 }));
}
/// <summary>
/// Verify that the constructor succeeds with valid agents.
/// </summary>
[Fact]
public void Constructor_ValidAgents_Succeeds()
{
// Arrange
var agent1 = CreateMockAgent("Research", "Research agent");
var agent2 = CreateMockAgent("Writer", "Writer agent");
// Act
var provider = new BackgroundAgentsProvider(new[] { agent1, agent2 });
// Assert
Assert.NotNull(provider);
}
#endregion
#region ProvideAIContextAsync Tests
/// <summary>
/// Verify that the provider returns tools and instructions.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_ReturnsToolsAndInstructionsAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var provider = new BackgroundAgentsProvider(new[] { agent });
var context = CreateInvokingContext();
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.NotNull(result.Instructions);
Assert.NotNull(result.Tools);
Assert.Equal(6, result.Tools!.Count());
}
/// <summary>
/// Verify that the instructions include agent names and descriptions.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_InstructionsIncludeAgentInfoAsync()
{
// Arrange
var agent1 = CreateMockAgent("Research", "Performs research");
var agent2 = CreateMockAgent("Writer", "Writes content");
var provider = new BackgroundAgentsProvider(new[] { agent1, agent2 });
var context = CreateInvokingContext();
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — agent info is appended to instructions
Assert.Contains("Research", result.Instructions);
Assert.Contains("Performs research", result.Instructions);
Assert.Contains("Writer", result.Instructions);
Assert.Contains("Writes content", result.Instructions);
}
#endregion
#region StartBackgroundTask Tests
/// <summary>
/// Verify that StartBackgroundTask returns a task ID.
/// </summary>
[Fact]
public async Task StartBackgroundTask_ReturnsTaskIdAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
// Act
object? result = await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Find information about AI",
["description"] = "Research AI topics",
});
// Assert
string text = GetStringResult(result);
Assert.Contains("1", text);
Assert.Contains("started", text);
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
}
/// <summary>
/// Verify that StartBackgroundTask with invalid agent name returns an error.
/// </summary>
[Fact]
public async Task StartBackgroundTask_InvalidAgentName_ReturnsErrorAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
// Act
object? result = await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "NonExistent",
["input"] = "Some input",
["description"] = "Some task",
});
// Assert
string text = GetStringResult(result);
Assert.Contains("Error", text);
Assert.Contains("NonExistent", text);
}
/// <summary>
/// Verify that StartBackgroundTask assigns sequential IDs.
/// </summary>
[Fact]
public async Task StartBackgroundTask_AssignsSequentialIdsAsync()
{
// Arrange
var tcs1 = new TaskCompletionSource<AgentResponse>();
var tcs2 = new TaskCompletionSource<AgentResponse>();
var callCount = 0;
var agent = CreateMockAgentWithCallback("Research", () =>
{
callCount++;
return callCount == 1 ? tcs1.Task : tcs2.Task;
});
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
// Act
object? result1 = await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Task 1",
["description"] = "First task",
});
object? result2 = await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Task 2",
["description"] = "Second task",
});
// Assert
Assert.Contains("1", GetStringResult(result1));
Assert.Contains("2", GetStringResult(result2));
tcs1.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
tcs2.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
}
#endregion
#region WaitForFirstCompletion Tests
/// <summary>
/// Verify that WaitForFirstCompletion returns the ID of a completed task.
/// </summary>
[Fact]
public async Task WaitForFirstCompletion_ReturnsCompletedTaskIdAsync()
{
// Arrange — use a single task to avoid Task.Run scheduling races.
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
AIFunction waitForFirst = GetTool(tools, "background_agents_wait_for_first_completion");
// Start one task
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Task 1",
["description"] = "First task",
});
// Complete the task
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Result 1")));
// Act
object? result = await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int> { 1 },
});
// Assert
string text = GetStringResult(result);
Assert.Contains("1", text);
Assert.Contains("finished with status: Completed", text);
}
/// <summary>
/// Verify that WaitForFirstCompletion with empty list returns an error.
/// </summary>
[Fact]
public async Task WaitForFirstCompletion_EmptyList_ReturnsErrorAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction waitForFirst = GetTool(tools, "background_agents_wait_for_first_completion");
// Act
object? result = await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int>(),
});
// Assert
Assert.Contains("Error", GetStringResult(result));
}
#endregion
#region GetBackgroundTaskResults Tests
/// <summary>
/// Verify that GetBackgroundTaskResults returns the result text of a completed task.
/// </summary>
[Fact]
public async Task GetBackgroundTaskResults_CompletedTask_ReturnsResultTextAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
AIFunction waitForFirst = GetTool(tools, "background_agents_wait_for_first_completion");
AIFunction getResults = GetTool(tools, "background_agents_get_task_results");
// Start a task
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research",
});
// Complete it
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "AI is fascinating!")));
// Wait for completion to finalize state
await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int> { 1 },
});
// Act
object? result = await getResults.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
});
// Assert
Assert.Contains("AI is fascinating!", GetStringResult(result));
}
/// <summary>
/// Verify that GetBackgroundTaskResults for a still-running task returns status info.
/// </summary>
[Fact]
public async Task GetBackgroundTaskResults_RunningTask_ReturnsStatusAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
AIFunction getResults = GetTool(tools, "background_agents_get_task_results");
// Start a task (don't complete it)
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research",
});
// Act
object? result = await getResults.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
});
// Assert
Assert.Contains("still running", GetStringResult(result));
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
}
/// <summary>
/// Verify that GetBackgroundTaskResults for a nonexistent task returns an error.
/// </summary>
[Fact]
public async Task GetBackgroundTaskResults_NonexistentTask_ReturnsErrorAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction getResults = GetTool(tools, "background_agents_get_task_results");
// Act
object? result = await getResults.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 999,
});
// Assert
Assert.Contains("Error", GetStringResult(result));
}
/// <summary>
/// Verify that GetBackgroundTaskResults for a failed task returns the error.
/// </summary>
[Fact]
public async Task GetBackgroundTaskResults_FailedTask_ReturnsErrorTextAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
AIFunction waitForFirst = GetTool(tools, "background_agents_wait_for_first_completion");
AIFunction getResults = GetTool(tools, "background_agents_get_task_results");
// Start a task
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research",
});
// Fail it
tcs.SetException(new InvalidOperationException("Connection failed"));
// Wait for completion to finalize state
await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int> { 1 },
});
// Act
object? result = await getResults.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
});
// Assert
string text = GetStringResult(result);
Assert.Contains("failed", text);
Assert.Contains("Connection failed", text);
}
#endregion
#region GetAllTasks Tests
/// <summary>
/// Verify that GetAllTasks returns running tasks with descriptions and status.
/// </summary>
[Fact]
public async Task GetAllTasks_ReturnsRunningTasksAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
AIFunction getAllTasks = GetTool(tools, "background_agents_get_all_tasks");
// Start a task
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research task",
});
// Act
object? result = await getAllTasks.InvokeAsync(new AIFunctionArguments());
// Assert
string text = GetStringResult(result);
Assert.Contains("1", text);
Assert.Contains("Research", text);
Assert.Contains("AI research task", text);
Assert.Contains("Running", text);
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
}
/// <summary>
/// Verify that GetAllTasks returns completed tasks with their status.
/// </summary>
[Fact]
public async Task GetAllTasks_ShowsCompletedTasksAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
AIFunction waitForFirst = GetTool(tools, "background_agents_wait_for_first_completion");
AIFunction getAllTasks = GetTool(tools, "background_agents_get_all_tasks");
// Start and complete a task
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research",
});
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int> { 1 },
});
// Act
object? result = await getAllTasks.InvokeAsync(new AIFunctionArguments());
// Assert
string text = GetStringResult(result);
Assert.Contains("Completed", text);
Assert.Contains("Research", text);
}
/// <summary>
/// Verify that GetAllTasks returns no tasks when none exist.
/// </summary>
[Fact]
public async Task GetAllTasks_NoTasks_ReturnsNoneAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction getAllTasks = GetTool(tools, "background_agents_get_all_tasks");
// Act
object? result = await getAllTasks.InvokeAsync(new AIFunctionArguments());
// Assert
Assert.Contains("No tasks", GetStringResult(result));
}
#endregion
#region ContinueTask Tests
/// <summary>
/// Verify that ContinueTask resumes a completed task with new input.
/// </summary>
[Fact]
public async Task ContinueTask_CompletedTask_ResumesAsync()
{
// Arrange
var tcs1 = new TaskCompletionSource<AgentResponse>();
var tcs2 = new TaskCompletionSource<AgentResponse>();
var callCount = 0;
var agent = CreateMockAgentWithCallback("Research", () =>
{
callCount++;
return callCount == 1 ? tcs1.Task : tcs2.Task;
});
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
AIFunction waitForFirst = GetTool(tools, "background_agents_wait_for_first_completion");
AIFunction continueTask = GetTool(tools, "background_agents_continue_task");
AIFunction getResults = GetTool(tools, "background_agents_get_task_results");
// Start and complete a task
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research",
});
tcs1.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "First result")));
await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int> { 1 },
});
// Act — continue the task
object? continueResult = await continueTask.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
["text"] = "Please elaborate",
});
// Assert — task is resumed
Assert.Contains("continued", GetStringResult(continueResult));
// Complete the second run
tcs2.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Elaborated result")));
await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int> { 1 },
});
object? result = await getResults.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
});
Assert.Contains("Elaborated result", GetStringResult(result));
}
/// <summary>
/// Verify that ContinueTask on a running task returns an error.
/// </summary>
[Fact]
public async Task ContinueTask_RunningTask_ReturnsErrorAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
AIFunction continueTask = GetTool(tools, "background_agents_continue_task");
// Start a task (don't complete it)
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research",
});
// Act
object? result = await continueTask.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
["text"] = "More input",
});
// Assert
Assert.Contains("still running", GetStringResult(result));
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
}
/// <summary>
/// Verify that ContinueTask on a nonexistent task returns an error.
/// </summary>
[Fact]
public async Task ContinueTask_NonexistentTask_ReturnsErrorAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction continueTask = GetTool(tools, "background_agents_continue_task");
// Act
object? result = await continueTask.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 999,
["text"] = "More input",
});
// Assert
Assert.Contains("Error", GetStringResult(result));
}
#endregion
#region ClearCompletedTask Tests
/// <summary>
/// Verify that ClearCompletedTask removes a terminal task.
/// </summary>
[Fact]
public async Task ClearCompletedTask_RemovesTerminalTaskAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
AIFunction waitForFirst = GetTool(tools, "background_agents_wait_for_first_completion");
AIFunction clearTask = GetTool(tools, "background_agents_clear_completed_task");
AIFunction getResults = GetTool(tools, "background_agents_get_task_results");
// Start and complete a task
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research",
});
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Result")));
await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int> { 1 },
});
// Act
object? clearResult = await clearTask.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
});
// Assert — task is cleared
Assert.Contains("cleared", GetStringResult(clearResult));
// Verify it's gone
object? getResult = await getResults.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
});
Assert.Contains("Error", GetStringResult(getResult));
}
/// <summary>
/// Verify that ClearCompletedTask on a running task returns an error.
/// </summary>
[Fact]
public async Task ClearCompletedTask_RunningTask_ReturnsErrorAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task");
AIFunction clearTask = GetTool(tools, "background_agents_clear_completed_task");
// Start a task (don't complete it)
await startBackgroundTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = "Research",
["input"] = "Research AI",
["description"] = "AI research",
});
// Act
object? result = await clearTask.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 1,
});
// Assert
Assert.Contains("still running", GetStringResult(result));
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
}
/// <summary>
/// Verify that ClearCompletedTask on a nonexistent task returns an error.
/// </summary>
[Fact]
public async Task ClearCompletedTask_NonexistentTask_ReturnsErrorAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var (tools, _) = await CreateToolsWithProviderAsync(agent);
AIFunction clearTask = GetTool(tools, "background_agents_clear_completed_task");
// Act
object? result = await clearTask.InvokeAsync(new AIFunctionArguments
{
["taskId"] = 999,
});
// Assert
Assert.Contains("Error", GetStringResult(result));
}
#endregion
#region StateKeys Tests
/// <summary>
/// Verify that the provider exposes state keys.
/// </summary>
[Fact]
public void StateKeys_ReturnsExpectedKeys()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var provider = new BackgroundAgentsProvider(new[] { agent });
// Act
var keys = provider.StateKeys;
// Assert
Assert.NotNull(keys);
Assert.Equal(2, keys.Count);
}
#endregion
#region CurrentRunContext Isolation Tests
/// <summary>
/// Verify that StartBackgroundTask does not corrupt CurrentRunContext of the calling agent.
/// Because RunAsync is a non-async method that synchronously sets the static AsyncLocal
/// CurrentRunContext, the provider must isolate the background agent call to prevent overwriting
/// the outer agent's context.
/// </summary>
[Fact]
public async Task StartBackgroundTask_DoesNotCorruptCurrentRunContextAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var agent = CreateMockAgentWithRunResult("Research", tcs.Task);
var (tools, _) = await CreateToolsWithProviderAsync(agent);
var startTool = GetTool(tools, "background_agents_start_task");
AgentRunContext? contextBefore = AIAgent.CurrentRunContext;
// Act — invoke StartBackgroundTask; this calls agent.RunAsync internally.
var args = new AIFunctionArguments(new Dictionary<string, object?>
{
["agentName"] = "Research",
["input"] = "Do work",
["description"] = "test task",
});
await startTool.InvokeAsync(args);
// Assert — CurrentRunContext should be unchanged.
Assert.Equal(contextBefore, AIAgent.CurrentRunContext);
// Clean up
tcs.SetResult(new AgentResponse(new List<ChatMessage> { new(ChatRole.Assistant, "done") }));
}
#endregion
#region Options Tests
/// <summary>
/// Verify that custom instructions from options override the default instructions but agent list is still injected via placeholder.
/// </summary>
[Fact]
public async Task CustomInstructions_OverridesDefaultInstructionsAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
const string CustomInstructions = "These are custom background agent instructions.\n{background_agents}";
var options = new BackgroundAgentsProviderOptions { Instructions = CustomInstructions };
var provider = new BackgroundAgentsProvider(new[] { agent }, options);
var context = CreateInvokingContext();
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — custom instructions replace default, agent list is injected via {sub_agents} placeholder
Assert.Contains("These are custom background agent instructions.", result.Instructions);
Assert.Contains("Research", result.Instructions);
}
/// <summary>
/// Verify that default instructions contain tool reference and agent names.
/// </summary>
[Fact]
public async Task DefaultInstructions_ContainsToolReferenceAndAgentListAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var provider = new BackgroundAgentsProvider(new[] { agent });
var context = CreateInvokingContext();
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — instructions contain tool usage guidance and agent list
Assert.Contains("background_agents_*", result.Instructions);
Assert.Contains("background_agents_clear_completed_task", result.Instructions);
Assert.Contains("Research", result.Instructions);
Assert.Contains("Research agent", result.Instructions);
}
/// <summary>
/// Verify that a custom AgentListBuilder function is used to build the agent list text.
/// </summary>
[Fact]
public async Task CustomAgentListBuilder_UsedForAgentListAsync()
{
// Arrange
var agent = CreateMockAgent("Research", "Research agent");
var options = new BackgroundAgentsProviderOptions
{
AgentListBuilder = agents => $"Custom list: {string.Join(", ", agents.Keys)}",
};
var provider = new BackgroundAgentsProvider(new[] { agent }, options);
var context = CreateInvokingContext();
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — custom agent list builder output is in instructions
Assert.Contains("Custom list: Research", result.Instructions);
Assert.DoesNotContain("Available background agents:", result.Instructions);
}
#endregion
#region Helper Methods
private static AIAgent CreateMockAgent(string? name, string? description)
{
var mock = new Mock<AIAgent>();
mock.SetupGet(a => a.Name).Returns(name!);
mock.SetupGet(a => a.Description).Returns(description);
return mock.Object;
}
private static AIAgent CreateMockAgentWithRunResult(string name, Task<AgentResponse> result)
{
var mock = new Mock<AIAgent>();
mock.SetupGet(a => a.Name).Returns(name);
mock.Protected()
.Setup<ValueTask<AgentSession>>(
"CreateSessionCoreAsync",
ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask<AgentSession>(new ChatClientAgentSession()));
mock.Protected()
.Setup<Task<AgentResponse>>(
"RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.Returns(result);
return mock.Object;
}
private static AIAgent CreateMockAgentWithCallback(string name, Func<Task<AgentResponse>> callback)
{
var mock = new Mock<AIAgent>();
mock.SetupGet(a => a.Name).Returns(name);
mock.Protected()
.Setup<ValueTask<AgentSession>>(
"CreateSessionCoreAsync",
ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask<AgentSession>(new ChatClientAgentSession()));
mock.Protected()
.Setup<Task<AgentResponse>>(
"RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.Returns(callback);
return mock.Object;
}
private static async Task<(IEnumerable<AITool> Tools, BackgroundAgentsProvider Provider)> CreateToolsWithProviderAsync(AIAgent agent)
{
var provider = new BackgroundAgentsProvider(new[] { agent });
var context = CreateInvokingContext();
AIContext result = await provider.InvokingAsync(context);
return (result.Tools!, provider);
}
private static AIContextProvider.InvokingContext CreateInvokingContext()
{
var mockAgent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
return new AIContextProvider.InvokingContext(mockAgent, session, new AIContext());
#pragma warning restore MAAI001
}
private static AIFunction GetTool(IEnumerable<AITool> tools, string name)
{
return (AIFunction)tools.First(t => t is AIFunction f && f.Name == name);
}
private static string GetStringResult(object? result)
{
var element = Assert.IsType<JsonElement>(result);
return element.GetString()!;
}
#endregion
}
@@ -0,0 +1,181 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory;
/// <summary>
/// Unit tests for the <see cref="FileEditor"/> helper that backs the <c>replace</c> and
/// <c>replace_lines</c> tools.
/// </summary>
public class FileEditorTests
{
#region ApplyReplace
[Fact]
public void ApplyReplace_SingleOccurrence_ReplacesAndReturnsCount()
{
// Act
(string content, int count) = FileEditor.ApplyReplace("Hello world", "world", "there", replaceAll: false);
// Assert
Assert.Equal("Hello there", content);
Assert.Equal(1, count);
}
[Fact]
public void ApplyReplace_ReplaceAll_ReplacesEveryOccurrence()
{
// Act
(string content, int count) = FileEditor.ApplyReplace("a a a", "a", "b", replaceAll: true);
// Assert
Assert.Equal("b b b", content);
Assert.Equal(3, count);
}
[Fact]
public void ApplyReplace_EmptyOldString_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => FileEditor.ApplyReplace("content", string.Empty, "x", replaceAll: false));
}
[Fact]
public void ApplyReplace_NotFound_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => FileEditor.ApplyReplace("content", "missing", "x", replaceAll: false));
}
[Fact]
public void ApplyReplace_MultipleOccurrences_WithoutReplaceAll_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => FileEditor.ApplyReplace("a a a", "a", "b", replaceAll: false));
}
#endregion
#region ApplyReplaceLines
[Fact]
public void ApplyReplaceLines_ReplacesSpecifiedLine()
{
// Act — new_line is literal; the caller supplies the trailing newline to keep it.
string result = FileEditor.ApplyReplaceLines(
"line1\nline2\nline3",
new List<FileLineEdit> { new() { LineNumber = 2, NewLine = "CHANGED\n" } });
// Assert
Assert.Equal("line1\nCHANGED\nline3", result);
}
[Fact]
public void ApplyReplaceLines_EmptyEdits_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => FileEditor.ApplyReplaceLines("line1", new List<FileLineEdit>()));
}
[Fact]
public void ApplyReplaceLines_OutOfRange_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => FileEditor.ApplyReplaceLines(
"line1\nline2",
new List<FileLineEdit> { new() { LineNumber = 5, NewLine = "X" } }));
}
[Fact]
public void ApplyReplaceLines_DuplicateLineNumber_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => FileEditor.ApplyReplaceLines(
"line1\nline2",
new List<FileLineEdit>
{
new() { LineNumber = 1, NewLine = "A" },
new() { LineNumber = 1, NewLine = "B" },
}));
}
[Fact]
public void ApplyReplaceLines_LiteralNewLineControlsTrailingNewline()
{
// Act — the literal new_line keeps the trailing newline the caller provides.
string result = FileEditor.ApplyReplaceLines(
"line1\nline2\n",
new List<FileLineEdit> { new() { LineNumber = 1, NewLine = "CHANGED\n" } });
// Assert
Assert.Equal("CHANGED\nline2\n", result);
}
[Fact]
public void ApplyReplaceLines_PreservesCrlfWhenCallerSuppliesIt()
{
// Act — a CRLF file keeps CRLF endings when the caller supplies "\r\n".
string result = FileEditor.ApplyReplaceLines(
"line1\r\nline2\r\nline3",
new List<FileLineEdit> { new() { LineNumber = 2, NewLine = "CHANGED\r\n" } });
// Assert
Assert.Equal("line1\r\nCHANGED\r\nline3", result);
}
[Fact]
public void ApplyReplaceLines_EmptyNewLine_DeletesMiddleLine()
{
// Act — an empty new_line removes the line, including its line break.
string result = FileEditor.ApplyReplaceLines(
"line1\nline2\nline3\n",
new List<FileLineEdit> { new() { LineNumber = 2, NewLine = string.Empty } });
// Assert
Assert.Equal("line1\nline3\n", result);
}
[Fact]
public void ApplyReplaceLines_EmptyNewLine_DeletesLastLineWithoutTerminator()
{
// Act
string result = FileEditor.ApplyReplaceLines(
"line1\nline2",
new List<FileLineEdit> { new() { LineNumber = 2, NewLine = string.Empty } });
// Assert
Assert.Equal("line1\n", result);
}
[Fact]
public void ApplyReplaceLines_DeleteAndReplaceInSameCall()
{
// Act
string result = FileEditor.ApplyReplaceLines(
"a\nb\nc\n",
new List<FileLineEdit>
{
new() { LineNumber = 1, NewLine = string.Empty },
new() { LineNumber = 3, NewLine = "C\n" },
});
// Assert
Assert.Equal("b\nC\n", result);
}
[Fact]
public void ApplyReplaceLines_EmbeddedNewLine_ExpandsIntoMultipleLines()
{
// Act — a literal new_line may contain its own newlines to insert extra lines.
string result = FileEditor.ApplyReplaceLines(
"a\nb\nc\n",
new List<FileLineEdit> { new() { LineNumber = 2, NewLine = "b1\nb2\n" } });
// Assert
Assert.Equal("a\nb1\nb2\nc\n", result);
}
#endregion
}
@@ -0,0 +1,658 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory;
public class InMemoryAgentFileStoreTests
{
[Fact]
public async Task WriteAndReadFile_ReturnsContentAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
// Act
await store.WriteAsync("notes.md", "Hello world");
var content = await store.ReadAsync("notes.md");
// Assert
Assert.Equal("Hello world", content);
}
[Fact]
public async Task ReadFile_NonExistent_ReturnsNullAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
// Act
var content = await store.ReadAsync("nonexistent.md");
// Assert
Assert.Null(content);
}
[Fact]
public async Task WriteFile_OverwritesExistingAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("notes.md", "Original");
// Act
await store.WriteAsync("notes.md", "Updated");
var content = await store.ReadAsync("notes.md");
// Assert
Assert.Equal("Updated", content);
}
[Fact]
public async Task DeleteFile_ExistingFile_ReturnsTrueAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("notes.md", "Content");
// Act
var deleted = await store.DeleteAsync("notes.md");
// Assert
Assert.True(deleted);
Assert.Null(await store.ReadAsync("notes.md"));
}
[Fact]
public async Task DeleteFile_NonExistent_ReturnsFalseAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
// Act
var deleted = await store.DeleteAsync("nonexistent.md");
// Assert
Assert.False(deleted);
}
[Fact]
public async Task ListFiles_ReturnsDirectChildrenAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("folder/file1.md", "Content 1");
await store.WriteAsync("folder/file2.md", "Content 2");
await store.WriteAsync("folder/sub/file3.md", "Content 3");
await store.WriteAsync("other/file4.md", "Content 4");
// Act
var files = (await store.ListChildrenAsync("folder"))
.Where(e => e.Type == FileStoreEntry.File)
.Select(e => e.Name)
.ToList();
// Assert
Assert.Equal(2, files.Count);
Assert.Contains("file1.md", files);
Assert.Contains("file2.md", files);
}
[Fact]
public async Task ListFiles_EmptyDirectory_ReturnsEmptyAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
// Act
var files = (await store.ListChildrenAsync("empty"))
.Where(e => e.Type == FileStoreEntry.File)
.Select(e => e.Name)
.ToList();
// Assert
Assert.Empty(files);
}
[Fact]
public async Task ListFiles_RootDirectory_ReturnsRootFilesAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("root.md", "Content");
await store.WriteAsync("folder/nested.md", "Content");
// Act
var files = (await store.ListChildrenAsync(""))
.Where(e => e.Type == FileStoreEntry.File)
.Select(e => e.Name)
.ToList();
// Assert
Assert.Single(files);
Assert.Equal("root.md", files[0]);
}
[Fact]
public async Task ListFiles_IncludesDescriptionFilesAsync()
{
// Arrange — the store is dumb; it returns all files including _description.md
var store = new InMemoryAgentFileStore();
await store.WriteAsync("folder/notes.md", "Content");
await store.WriteAsync("folder/notes_description.md", "Desc");
// Act
var files = (await store.ListChildrenAsync("folder"))
.Where(e => e.Type == FileStoreEntry.File)
.Select(e => e.Name)
.ToList();
// Assert
Assert.Equal(2, files.Count);
Assert.Contains("notes.md", files);
Assert.Contains("notes_description.md", files);
}
[Fact]
public async Task FileExists_ExistingFile_ReturnsTrueAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("notes.md", "Content");
// Act & Assert
Assert.True(await store.FileExistsAsync("notes.md"));
}
[Fact]
public async Task FileExists_NonExistent_ReturnsFalseAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
// Act & Assert
Assert.False(await store.FileExistsAsync("nonexistent.md"));
}
[Fact]
public async Task SearchFiles_FindsMatchingContentAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("folder/notes.md", "The quick brown fox jumps over the lazy dog");
await store.WriteAsync("folder/other.md", "No match here");
// Act
var results = await store.SearchAsync("folder", "brown fox");
// Assert
Assert.Single(results);
Assert.Equal("notes.md", results[0].FileName);
Assert.Contains("brown fox", results[0].Snippet);
}
[Fact]
public async Task SearchFiles_ReturnsMatchingLineNumbersAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("folder/notes.md", "Line one\nLine two with match\nLine three\nLine four with match");
// Act
var results = await store.SearchAsync("folder", "match");
// Assert
Assert.Single(results);
Assert.Equal(2, results[0].MatchingLines.Count);
Assert.Equal(2, results[0].MatchingLines[0].LineNumber);
Assert.Equal("Line two with match", results[0].MatchingLines[0].Line);
Assert.Equal(4, results[0].MatchingLines[1].LineNumber);
Assert.Equal("Line four with match", results[0].MatchingLines[1].Line);
}
[Fact]
public async Task SearchFiles_CaseInsensitiveAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("folder/notes.md", "Important Data Here");
// Act
var results = await store.SearchAsync("folder", "important data");
// Assert
Assert.Single(results);
}
[Fact]
public async Task SearchFiles_SupportsRegexPatternAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("folder/notes.md", "Error: something went wrong\nWarning: check this\nInfo: all good");
// Act
var results = await store.SearchAsync("folder", "error|warning");
// Assert
Assert.Single(results);
Assert.Equal(2, results[0].MatchingLines.Count);
Assert.Equal(1, results[0].MatchingLines[0].LineNumber);
Assert.Equal(2, results[0].MatchingLines[1].LineNumber);
}
[Fact]
public async Task SearchFiles_SupportsRegexWithSpecialCharactersAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("folder/code.cs", "var x = 42;\nvar y = 100;\nconst z = 7;");
// Act — regex matching lines starting with "var"
var results = await store.SearchAsync("folder", @"^var\b");
// Assert
Assert.Single(results);
Assert.Equal(2, results[0].MatchingLines.Count);
}
[Fact]
public async Task SearchFiles_WithGlobPattern_FiltersFilesAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("folder/notes.md", "Important data");
await store.WriteAsync("folder/data.txt", "Important data");
await store.WriteAsync("folder/code.cs", "Important data");
// Act — only search markdown files
var results = await store.SearchAsync("folder", "Important", globPattern: "*.md");
// Assert
Assert.Single(results);
Assert.Equal("notes.md", results[0].FileName);
}
[Fact]
public async Task SearchFiles_WithGlobPattern_MultipleExtensionsAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("folder/notes.md", "match here");
await store.WriteAsync("folder/data.txt", "match here");
await store.WriteAsync("folder/code.cs", "match here");
// Act — search both md and txt files
var resultsMd = await store.SearchAsync("folder", "match", globPattern: "*.md");
var resultsTxt = await store.SearchAsync("folder", "match", globPattern: "*.txt");
// Assert
Assert.Single(resultsMd);
Assert.Equal("notes.md", resultsMd[0].FileName);
Assert.Single(resultsTxt);
Assert.Equal("data.txt", resultsTxt[0].FileName);
}
[Fact]
public async Task SearchFiles_WithGlobPattern_PrefixMatchAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("folder/research_ai.md", "findings");
await store.WriteAsync("folder/research_ml.md", "findings");
await store.WriteAsync("folder/notes.md", "findings");
// Act
var results = await store.SearchAsync("folder", "findings", globPattern: "research*");
// Assert
Assert.Equal(2, results.Count);
Assert.All(results, r => Assert.StartsWith("research", r.FileName));
}
[Fact]
public async Task SearchFiles_WithNullGlobPattern_SearchesAllFilesAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("folder/notes.md", "match");
await store.WriteAsync("folder/data.txt", "match");
// Act
var results = await store.SearchAsync("folder", "match", globPattern: null);
// Assert
Assert.Equal(2, results.Count);
}
[Fact]
public async Task SearchFiles_NoMatch_ReturnsEmptyAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("folder/notes.md", "Some content");
// Act
var results = await store.SearchAsync("folder", "nonexistent query");
// Assert
Assert.Empty(results);
}
[Fact]
public async Task SearchFiles_IgnoresSubdirectoryFilesAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("folder/notes.md", "Match here");
await store.WriteAsync("folder/sub/deep.md", "Match here too");
// Act
var results = await store.SearchAsync("folder", "Match");
// Assert
Assert.Single(results);
Assert.Equal("notes.md", results[0].FileName);
}
[Fact]
public async Task SearchFiles_Snippet_IncludesSurroundingContextAsync()
{
// Arrange — place the match in the middle of a long line so ±50 chars are available.
var store = new InMemoryAgentFileStore();
string padding = new('A', 60);
string content = $"{padding}MATCH_HERE{padding}";
await store.WriteAsync("folder/file.md", content);
// Act
var results = await store.SearchAsync("folder", "MATCH_HERE");
// Assert — snippet should contain the match and surrounding context (up to ±50 chars).
Assert.Single(results);
string snippet = results[0].Snippet;
Assert.Contains("MATCH_HERE", snippet);
Assert.True(snippet.Length <= 50 + "MATCH_HERE".Length + 50, "Snippet should be at most ±50 chars around the match.");
Assert.True(snippet.Length > "MATCH_HERE".Length, "Snippet should include surrounding context.");
}
[Fact]
public async Task SearchFiles_Snippet_MatchNearStartOfFileAsync()
{
// Arrange — match is at the very beginning, so no leading context is available.
var store = new InMemoryAgentFileStore();
string trailing = new('B', 80);
string content = $"MATCH{trailing}";
await store.WriteAsync("folder/file.md", content);
// Act
var results = await store.SearchAsync("folder", "MATCH");
// Assert — snippet should start at the beginning of the file.
Assert.Single(results);
Assert.StartsWith("MATCH", results[0].Snippet);
Assert.True(results[0].Snippet.Length <= "MATCH".Length + 50);
}
[Fact]
public async Task SearchFiles_Snippet_MatchNearEndOfFileAsync()
{
// Arrange — match is at the very end, so no trailing context is available.
var store = new InMemoryAgentFileStore();
string leading = new('C', 80);
string content = $"{leading}MATCH";
await store.WriteAsync("folder/file.md", content);
// Act
var results = await store.SearchAsync("folder", "MATCH");
// Assert — snippet should end at the end of the file.
Assert.Single(results);
Assert.EndsWith("MATCH", results[0].Snippet);
Assert.True(results[0].Snippet.Length <= 50 + "MATCH".Length);
}
[Fact]
public async Task SearchFiles_Snippet_UsesFirstMatchPositionAsync()
{
// Arrange — "target" appears on lines 1 and 3, but the regex only matches line 3
// because we require the word "UNIQUE" which only appears on line 3.
var store = new InMemoryAgentFileStore();
const string Content = "Line one has some text\nLine two is filler\nLine three has UNIQUE_MARKER here";
await store.WriteAsync("folder/file.md", Content);
// Act
var results = await store.SearchAsync("folder", "UNIQUE_MARKER");
// Assert — snippet should be from around line 3, not line 1.
Assert.Single(results);
Assert.Contains("UNIQUE_MARKER", results[0].Snippet);
Assert.Contains("Line three", results[0].Snippet);
}
[Fact]
public async Task SearchFiles_Snippet_CorrectForMultiLineMatchAsync()
{
// Arrange — match is on the second line with enough distance from line 1
// that the ±50 char snippet window does not reach the start of the file.
var store = new InMemoryAgentFileStore();
string line1 = new('X', 100);
string line2 = new string('Y', 60) + "FIND_ME" + new string('Z', 60);
string line3 = new('W', 100);
string content = $"{line1}\n{line2}\n{line3}";
await store.WriteAsync("folder/file.md", content);
// Act
var results = await store.SearchAsync("folder", "FIND_ME");
// Assert — snippet should contain the match from line 2.
Assert.Single(results);
Assert.Contains("FIND_ME", results[0].Snippet);
// The match is at offset 101 (line1=100 + '\n') + 60 = 161.
// snippetStart = 161 - 50 = 111, which is well past line 1 (ends at offset 100).
// So line 1 content should not appear in the snippet.
Assert.DoesNotContain("XXXX", results[0].Snippet);
}
[Fact]
public async Task PathNormalization_HandlesBackslashesAndTrailingSlashesAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
// Act
await store.WriteAsync("folder\\file.md", "Content");
var content = await store.ReadAsync("folder/file.md");
// Assert
Assert.Equal("Content", content);
}
[Fact]
public async Task WriteFile_PathTraversal_ThrowsAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => store.WriteAsync("../escape.md", "Content"));
}
[Fact]
public async Task ReadFile_PathTraversal_ThrowsAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => store.ReadAsync("folder/../../escape.md"));
}
[Fact]
public async Task WriteFile_AbsolutePath_ThrowsAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => store.WriteAsync("/etc/passwd", "Content"));
}
[Fact]
public async Task WriteFile_DoubleDotsInFileName_AllowedAsync()
{
// Arrange — "notes..md" contains ".." as a substring but not as a path segment.
var store = new InMemoryAgentFileStore();
// Act
await store.WriteAsync("notes..md", "Content");
var content = await store.ReadAsync("notes..md");
// Assert
Assert.Equal("Content", content);
}
[Fact]
public async Task WriteFile_DriveRootedPath_ThrowsAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => store.WriteAsync("C:\\temp\\file.md", "Content"));
}
[Fact]
public async Task ListFiles_PathTraversal_ThrowsAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => store.ListChildrenAsync("../other"));
}
[Fact]
public async Task ListDirectories_PathTraversal_ThrowsAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => store.ListChildrenAsync("../other"));
}
[Fact]
public async Task SearchFiles_Recursive_FindsDescendantsAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("notes.md", "Match here");
await store.WriteAsync("reports/q1.md", "Match here too");
await store.WriteAsync("reports/2024/q2.md", "Match here as well");
// Act
var results = await store.SearchAsync("", "Match", globPattern: null, recursive: true);
// Assert
Assert.Equal(3, results.Count);
var names = string.Join(",", results.Select(r => r.FileName).OrderBy(n => n, StringComparer.Ordinal));
Assert.Equal("notes.md,reports/2024/q2.md,reports/q1.md", names);
}
[Fact]
public async Task SearchFiles_Recursive_GlobScopesToSubtreeAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("notes.md", "Match here");
await store.WriteAsync("reports/q1.md", "Match here too");
await store.WriteAsync("reports/2024/q2.md", "Match here as well");
// Act
var results = await store.SearchAsync("", "Match", globPattern: "reports/**", recursive: true);
// Assert
Assert.Equal(2, results.Count);
var names = string.Join(",", results.Select(r => r.FileName).OrderBy(n => n, StringComparer.Ordinal));
Assert.Equal("reports/2024/q2.md,reports/q1.md", names);
}
[Fact]
public async Task SearchFiles_Recursive_GlobMatchesNestedExtensionAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("notes.md", "Match here");
await store.WriteAsync("reports/q1.txt", "Match here too");
await store.WriteAsync("reports/2024/q2.md", "Match here as well");
// Act
var results = await store.SearchAsync("", "Match", globPattern: "**/*.md", recursive: true);
// Assert
Assert.Equal(2, results.Count);
var names = string.Join(",", results.Select(r => r.FileName).OrderBy(n => n, StringComparer.Ordinal));
Assert.Equal("notes.md,reports/2024/q2.md", names);
}
[Fact]
public async Task ListDirectories_ReturnsDirectChildSubdirectoriesAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("root.md", "x");
await store.WriteAsync("reports/q1.md", "x");
await store.WriteAsync("reports/2024/q2.md", "x");
await store.WriteAsync("images/logo.png", "x");
// Act
var directories = (await store.ListChildrenAsync(""))
.Where(e => e.Type == FileStoreEntry.Directory)
.Select(e => e.Name)
.ToList();
// Assert
var sorted = string.Join(",", directories.OrderBy(d => d, StringComparer.Ordinal));
Assert.Equal("images,reports", sorted);
}
[Fact]
public async Task ListDirectories_NestedDirectory_ReturnsChildrenAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("reports/q1.md", "x");
await store.WriteAsync("reports/2024/q2.md", "x");
await store.WriteAsync("reports/2025/q3.md", "x");
// Act
var directories = (await store.ListChildrenAsync("reports"))
.Where(e => e.Type == FileStoreEntry.Directory)
.Select(e => e.Name)
.ToList();
// Assert
var sorted = string.Join(",", directories.OrderBy(d => d, StringComparer.Ordinal));
Assert.Equal("2024,2025", sorted);
}
[Fact]
public async Task ListDirectories_NoSubdirectories_ReturnsEmptyAsync()
{
// Arrange
var store = new InMemoryAgentFileStore();
await store.WriteAsync("root.md", "x");
// Act
var directories = (await store.ListChildrenAsync(""))
.Where(e => e.Type == FileStoreEntry.Directory)
.Select(e => e.Name)
.ToList();
// Assert
Assert.Empty(directories);
}
}
@@ -0,0 +1,174 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.FileSystemGlobbing;
namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory;
public class StorePathsTests
{
#region NormalizeRelativePath valid paths
[Theory]
[InlineData("file.md", "file.md")]
[InlineData("folder/file.md", "folder/file.md")]
[InlineData("a/b/c.txt", "a/b/c.txt")]
public void NormalizeRelativePath_ValidPath_ReturnsNormalized(string input, string expected)
{
// Act
string result = StorePaths.NormalizeRelativePath(input);
// Assert
Assert.Equal(expected, result);
}
[Theory]
[InlineData("folder\\file.md", "folder/file.md")]
[InlineData("a\\b\\c.txt", "a/b/c.txt")]
public void NormalizeRelativePath_Backslashes_NormalizesToForwardSlash(string input, string expected)
{
// Act
string result = StorePaths.NormalizeRelativePath(input);
// Assert
Assert.Equal(expected, result);
}
[Theory]
[InlineData("folder//file.md", "folder/file.md")]
[InlineData("a///b////c.txt", "a/b/c.txt")]
public void NormalizeRelativePath_ConsecutiveSeparators_Collapsed(string input, string expected)
{
// Act
string result = StorePaths.NormalizeRelativePath(input);
// Assert
Assert.Equal(expected, result);
}
[Fact]
public void NormalizeRelativePath_TrailingSlash_Trimmed()
{
// Act
string result = StorePaths.NormalizeRelativePath("file.md/");
// Assert
Assert.Equal("file.md", result);
}
[Theory]
[InlineData("/file.md")]
[InlineData("/folder/file.md/")]
public void NormalizeRelativePath_LeadingSlash_Throws(string input)
{
// Act & Assert — leading slash is treated as a rooted path.
Assert.Throws<ArgumentException>(() => StorePaths.NormalizeRelativePath(input));
}
#endregion
#region NormalizeRelativePath rejected paths
[Theory]
[InlineData("../file.md")]
[InlineData("folder/../file.md")]
[InlineData("./file.md")]
[InlineData("folder/./file.md")]
public void NormalizeRelativePath_TraversalSegments_Throws(string input)
{
// Act & Assert
Assert.Throws<ArgumentException>(() => StorePaths.NormalizeRelativePath(input));
}
[Theory]
[InlineData("C:\\file.md")]
[InlineData("C:/file.md")]
[InlineData("D:file.md")]
public void NormalizeRelativePath_DriveRoot_Throws(string input)
{
// Act & Assert
Assert.Throws<ArgumentException>(() => StorePaths.NormalizeRelativePath(input));
}
[Fact]
public void NormalizeRelativePath_EmptyFile_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => StorePaths.NormalizeRelativePath(""));
}
[Fact]
public void NormalizeRelativePath_WhitespaceOnlyFile_Throws()
{
// Act & Assert — whitespace-only paths are rejected as invalid file names.
Assert.Throws<ArgumentException>(() => StorePaths.NormalizeRelativePath(" "));
}
#endregion
#region NormalizeRelativePath directory mode
[Fact]
public void NormalizeRelativePath_EmptyDirectory_ReturnsEmpty()
{
// Act
string result = StorePaths.NormalizeRelativePath("", isDirectory: true);
// Assert
Assert.Equal("", result);
}
[Theory]
[InlineData("folder", "folder")]
[InlineData("a/b", "a/b")]
[InlineData("a\\b/", "a/b")]
public void NormalizeRelativePath_DirectoryMode_NormalizesPath(string input, string expected)
{
// Act
string result = StorePaths.NormalizeRelativePath(input, isDirectory: true);
// Assert
Assert.Equal(expected, result);
}
[Fact]
public void NormalizeRelativePath_DirectoryTraversal_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => StorePaths.NormalizeRelativePath("../folder", isDirectory: true));
}
#endregion
#region CreateGlobMatcher and MatchesGlob
[Theory]
[InlineData("*.md", "notes.md", true)]
[InlineData("*.md", "notes.txt", false)]
[InlineData("research*", "research_results.md", true)]
[InlineData("research*", "notes.md", false)]
[InlineData("*.md", "NOTES.MD", true)] // case-insensitive
public void MatchesGlob_WithMatcher_MatchesCorrectly(string pattern, string fileName, bool expected)
{
// Arrange
Matcher matcher = StorePaths.CreateGlobMatcher(pattern);
// Act
bool result = StorePaths.MatchesGlob(fileName, matcher);
// Assert
Assert.Equal(expected, result);
}
[Fact]
public void MatchesGlob_NullMatcher_ReturnsTrue()
{
// Act
bool result = StorePaths.MatchesGlob("anything.txt", null);
// Assert
Assert.True(result);
}
#endregion
}
@@ -0,0 +1,314 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
using static Microsoft.Agents.AI.UnitTests.LoopTestHelpers;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AIJudgeLoopEvaluator"/> class.
/// </summary>
public class AIJudgeLoopEvaluatorTests
{
/// <summary>
/// Verify that the evaluator stops when the judge reports the request was answered.
/// </summary>
[Fact]
public async Task EvaluateAsync_Answered_StopsAsync()
{
// Arrange
var judgeClient = CreateJudgeClient("{\"answered\":true}");
var evaluator = new AIJudgeLoopEvaluator(judgeClient);
LoopContext context = CreateContext();
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.False(evaluation.ShouldReinvoke);
}
/// <summary>
/// Verify that when not answered the evaluator continues with feedback carrying the judge's gap analysis.
/// </summary>
[Fact]
public async Task EvaluateAsync_NotAnswered_ContinuesWithGapAnalysisAsync()
{
// Arrange
var judgeClient = CreateJudgeClient("{\"answered\":false,\"gapAnalysis\":\"the cost estimate is missing\"}");
var evaluator = new AIJudgeLoopEvaluator(judgeClient);
LoopContext context = CreateContext();
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.True(evaluation.ShouldReinvoke);
Assert.NotNull(evaluation.Feedback);
Assert.Contains("the cost estimate is missing", evaluation.Feedback!);
Assert.DoesNotContain(AIJudgeLoopEvaluator.GapAnalysisPlaceholder, evaluation.Feedback!);
}
/// <summary>
/// Verify that the evaluator falls back to text parsing and stops when the DONE verdict marker is present.
/// </summary>
[Fact]
public async Task EvaluateAsync_TextFallback_StopsWhenAnsweredAsync()
{
// Arrange
var judgeClient = CreateJudgeClient(AIJudgeLoopEvaluator.DoneVerdictMarker);
var evaluator = new AIJudgeLoopEvaluator(judgeClient);
LoopContext context = CreateContext();
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.False(evaluation.ShouldReinvoke);
}
/// <summary>
/// Verify that the gap-analysis placeholder is filled with a fallback token when no structured output is produced.
/// </summary>
[Fact]
public async Task EvaluateAsync_NotAnswered_TextFallback_InjectsUnknownGapAnalysisAsync()
{
// Arrange
var judgeClient = CreateJudgeClient(AIJudgeLoopEvaluator.MoreVerdictMarker);
var evaluator = new AIJudgeLoopEvaluator(judgeClient);
LoopContext context = CreateContext();
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.True(evaluation.ShouldReinvoke);
Assert.Contains("<unknown>", evaluation.Feedback!);
}
/// <summary>
/// Verify that the text fallback keeps looping for replies that merely contain the substring "ANSWERED" (for
/// example "UNANSWERED" or "NOT ANSWERED") rather than the explicit DONE verdict marker.
/// </summary>
[Theory]
[InlineData("UNANSWERED")]
[InlineData("NOT ANSWERED")]
[InlineData("The request is not yet answered.")]
public async Task EvaluateAsync_TextFallback_AmbiguousReply_ContinuesAsync(string reply)
{
// Arrange
var judgeClient = CreateJudgeClient(reply);
var evaluator = new AIJudgeLoopEvaluator(judgeClient);
LoopContext context = CreateContext();
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.True(evaluation.ShouldReinvoke);
}
/// <summary>
/// Verify that custom judge instructions from options are sent to the judge client.
/// </summary>
[Fact]
public async Task EvaluateAsync_CustomInstructions_AreSentToJudgeAsync()
{
// Arrange
List<ChatMessage>? judgeMessages = null;
var judgeMock = new Mock<IChatClient>();
judgeMock.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) => judgeMessages = msgs.ToList())
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "{\"answered\":true}")));
var evaluator = new AIJudgeLoopEvaluator(judgeMock.Object, new AIJudgeLoopEvaluatorOptions { Instructions = "CUSTOM JUDGE PROMPT" });
LoopContext context = CreateContext();
// Act
await evaluator.EvaluateAsync(context);
// Assert
Assert.NotNull(judgeMessages);
Assert.Contains(judgeMessages!, m => m.Role == ChatRole.System && m.Text == "CUSTOM JUDGE PROMPT");
}
/// <summary>
/// Verify that a custom feedback message template from options is honored.
/// </summary>
[Fact]
public async Task EvaluateAsync_CustomFeedbackMessageTemplate_IsHonoredAsync()
{
// Arrange
var judgeClient = CreateJudgeClient("{\"answered\":false,\"gapAnalysis\":\"add unit tests\"}");
const string Template = "Please address: " + AIJudgeLoopEvaluator.GapAnalysisPlaceholder;
var evaluator = new AIJudgeLoopEvaluator(judgeClient, new AIJudgeLoopEvaluatorOptions { FeedbackMessageTemplate = Template });
LoopContext context = CreateContext();
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.Equal("Please address: add unit tests", evaluation.Feedback);
}
/// <summary>
/// Verify that non-text content in the original request (for example an image) is forwarded to the judge
/// rather than being silently dropped when flattening the request to text.
/// </summary>
[Fact]
public async Task EvaluateAsync_NonTextRequestContent_IsForwardedToJudgeAsync()
{
// Arrange
List<ChatMessage>? judgeMessages = null;
var judgeMock = new Mock<IChatClient>();
judgeMock.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) => judgeMessages = msgs.ToList())
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "{\"answered\":true}")));
var evaluator = new AIJudgeLoopEvaluator(judgeMock.Object);
var imageContent = new DataContent(new byte[] { 1, 2, 3, 4 }, "image/png");
var context = new LoopContext(
new Mock<AIAgent>().Object,
new ChatClientAgentSession(),
[new ChatMessage(ChatRole.User, [imageContent])],
new AgentResponse([new ChatMessage(ChatRole.Assistant, "partial answer")]));
// Act
await evaluator.EvaluateAsync(context);
// Assert
Assert.NotNull(judgeMessages);
ChatMessage userMessage = Assert.Single(judgeMessages!, m => m.Role == ChatRole.User);
Assert.Contains(userMessage.Contents.OfType<DataContent>(), c => c.MediaType == "image/png");
}
/// <summary>
/// Verify that the constructor throws when the judge client is null.
/// </summary>
[Fact]
public void AIJudgeLoopEvaluator_NullClient_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("judgeClient", () => new AIJudgeLoopEvaluator(null!));
}
/// <summary>
/// Verify that EvaluateAsync throws when the context is null.
/// </summary>
[Fact]
public async Task EvaluateAsync_NullContext_ThrowsAsync()
{
// Arrange
var evaluator = new AIJudgeLoopEvaluator(CreateJudgeClient("{\"answered\":true}"));
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>("context", async () => await evaluator.EvaluateAsync(null!));
}
/// <summary>
/// Verify that supplied criteria are rendered into the default judge instructions as a bullet list and the
/// placeholder is consumed.
/// </summary>
[Fact]
public async Task EvaluateAsync_Criteria_AreRenderedIntoDefaultInstructionsAsync()
{
// Arrange
var judgeClient = CreateCapturingJudgeClient("{\"answered\":true}", out List<ChatMessage> judgeMessages);
var options = new AIJudgeLoopEvaluatorOptions { Criteria = ["Must cite sources", "Must be under 200 words"] };
var evaluator = new AIJudgeLoopEvaluator(judgeClient, options);
LoopContext context = CreateContext();
// Act
await evaluator.EvaluateAsync(context);
// Assert
string system = judgeMessages.Single(static m => m.Role == ChatRole.System).Text;
Assert.Contains("The response must satisfy all of the following criteria:", system);
Assert.Contains("- Must cite sources", system);
Assert.Contains("- Must be under 200 words", system);
Assert.DoesNotContain(AIJudgeLoopEvaluator.CriteriaPlaceholder, system);
}
/// <summary>
/// Verify that when no criteria are supplied the placeholder is removed and no criteria block is added to the
/// default instructions.
/// </summary>
[Fact]
public async Task EvaluateAsync_NoCriteria_LeavesDefaultInstructionsWithoutCriteriaBlockAsync()
{
// Arrange
var judgeClient = CreateCapturingJudgeClient("{\"answered\":true}", out List<ChatMessage> judgeMessages);
var evaluator = new AIJudgeLoopEvaluator(judgeClient);
LoopContext context = CreateContext();
// Act
await evaluator.EvaluateAsync(context);
// Assert
string system = judgeMessages.Single(static m => m.Role == ChatRole.System).Text;
Assert.DoesNotContain(AIJudgeLoopEvaluator.CriteriaPlaceholder, system);
Assert.DoesNotContain("The response must satisfy all of the following criteria:", system);
}
/// <summary>
/// Verify that criteria are injected at the placeholder location in custom instructions.
/// </summary>
[Fact]
public async Task EvaluateAsync_CustomInstructionsWithPlaceholder_InjectsCriteriaAsync()
{
// Arrange
var judgeClient = CreateCapturingJudgeClient("{\"answered\":true}", out List<ChatMessage> judgeMessages);
const string Instructions = "Judge the answer." + AIJudgeLoopEvaluator.CriteriaPlaceholder + " Be strict.";
var options = new AIJudgeLoopEvaluatorOptions { Instructions = Instructions, Criteria = ["Must include code"] };
var evaluator = new AIJudgeLoopEvaluator(judgeClient, options);
LoopContext context = CreateContext();
// Act
await evaluator.EvaluateAsync(context);
// Assert
string system = judgeMessages.Single(static m => m.Role == ChatRole.System).Text;
Assert.StartsWith("Judge the answer.", system);
Assert.EndsWith("Be strict.", system);
Assert.Contains("- Must include code", system);
Assert.DoesNotContain(AIJudgeLoopEvaluator.CriteriaPlaceholder, system);
}
/// <summary>
/// Verify that custom instructions without the placeholder do not receive the criteria.
/// </summary>
[Fact]
public async Task EvaluateAsync_CustomInstructionsWithoutPlaceholder_OmitsCriteriaAsync()
{
// Arrange
var judgeClient = CreateCapturingJudgeClient("{\"answered\":true}", out List<ChatMessage> judgeMessages);
const string Instructions = "Judge the answer and be strict.";
var options = new AIJudgeLoopEvaluatorOptions { Instructions = Instructions, Criteria = ["Must include code"] };
var evaluator = new AIJudgeLoopEvaluator(judgeClient, options);
LoopContext context = CreateContext();
// Act
await evaluator.EvaluateAsync(context);
// Assert
string system = judgeMessages.Single(static m => m.Role == ChatRole.System).Text;
Assert.Equal(Instructions, system);
}
private static LoopContext CreateContext() => new(
new Mock<AIAgent>().Object,
new ChatClientAgentSession(),
[new ChatMessage(ChatRole.User, "original question")],
new AgentResponse([new ChatMessage(ChatRole.Assistant, "partial answer")]));
}
@@ -0,0 +1,319 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="BackgroundTaskCompletionLoopEvaluator"/> class.
/// </summary>
public class BackgroundTaskCompletionLoopEvaluatorTests
{
/// <summary>
/// Verify that the constructor succeeds with no options and with a custom template.
/// </summary>
[Fact]
public void BackgroundTaskCompletionLoopEvaluator_ValidConstruction_Succeeds()
{
// Act & Assert
_ = new BackgroundTaskCompletionLoopEvaluator();
_ = new BackgroundTaskCompletionLoopEvaluator(new BackgroundTaskCompletionLoopEvaluatorOptions { FeedbackMessageTemplate = "custom" });
}
/// <summary>
/// Verify that evaluation throws when no <see cref="BackgroundAgentsProvider"/> can be resolved from the agent.
/// </summary>
[Fact]
public async Task EvaluateAsync_NoBackgroundAgentsProvider_ThrowsAsync()
{
// Arrange — a bare agent that resolves no providers.
var evaluator = new BackgroundTaskCompletionLoopEvaluator();
var context = CreateContext(new Mock<AIAgent>().Object, new ChatClientAgentSession());
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () => await evaluator.EvaluateAsync(context));
}
/// <summary>
/// Verify that the evaluator continues while a background task is still running and that the feedback lists the
/// running task and its count.
/// </summary>
[Fact]
public async Task EvaluateAsync_RunningTask_ContinuesWithFeedbackAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var backgroundAgent = CreateMockAgentWithRunResult("Research", tcs.Task);
var provider = new BackgroundAgentsProvider(new[] { backgroundAgent });
var session = new ChatClientAgentSession();
IEnumerable<AITool> tools = await CreateToolsForSessionAsync(provider, session);
await StartTaskAsync(tools, "Research", "Find information about AI", "Research AI topics");
AIAgent agent = CreateAgent(provider);
var evaluator = new BackgroundTaskCompletionLoopEvaluator();
LoopContext context = CreateContext(agent, session);
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.True(evaluation.ShouldReinvoke);
Assert.NotNull(evaluation.Feedback);
Assert.Contains("1 background task(s)", evaluation.Feedback!);
Assert.Contains("#1", evaluation.Feedback!);
Assert.Contains("Research", evaluation.Feedback!);
Assert.Contains("Research AI topics", evaluation.Feedback!);
// Cleanup — complete the in-flight task to avoid leaking a pending task.
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
}
/// <summary>
/// Verify that the evaluator stops once every background task has reached a terminal state.
/// </summary>
[Fact]
public async Task EvaluateAsync_AllTasksTerminal_StopsAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var backgroundAgent = CreateMockAgentWithRunResult("Research", tcs.Task);
var provider = new BackgroundAgentsProvider(new[] { backgroundAgent });
var session = new ChatClientAgentSession();
IEnumerable<AITool> tools = await CreateToolsForSessionAsync(provider, session);
await StartTaskAsync(tools, "Research", "Task 1", "First task");
// Complete the task and wait for it to be finalized.
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Result 1")));
await WaitForCompletionAsync(tools, 1);
AIAgent agent = CreateAgent(provider);
var evaluator = new BackgroundTaskCompletionLoopEvaluator();
LoopContext context = CreateContext(agent, session);
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.False(evaluation.ShouldReinvoke);
Assert.Null(evaluation.Feedback);
}
/// <summary>
/// Verify that the evaluator stops when no background tasks have been started.
/// </summary>
[Fact]
public async Task EvaluateAsync_NoTasks_StopsAsync()
{
// Arrange
var backgroundAgent = CreateMockAgent("Research", "Research agent");
var provider = new BackgroundAgentsProvider(new[] { backgroundAgent });
var session = new ChatClientAgentSession();
_ = await CreateToolsForSessionAsync(provider, session);
AIAgent agent = CreateAgent(provider);
var evaluator = new BackgroundTaskCompletionLoopEvaluator();
LoopContext context = CreateContext(agent, session);
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.False(evaluation.ShouldReinvoke);
Assert.Null(evaluation.Feedback);
}
/// <summary>
/// Verify that a custom feedback template is honored, including the running task list placeholder.
/// </summary>
[Fact]
public async Task EvaluateAsync_CustomTemplate_IsHonoredAsync()
{
// Arrange
var tcs = new TaskCompletionSource<AgentResponse>();
var backgroundAgent = CreateMockAgentWithRunResult("Research", tcs.Task);
var provider = new BackgroundAgentsProvider(new[] { backgroundAgent });
var session = new ChatClientAgentSession();
IEnumerable<AITool> tools = await CreateToolsForSessionAsync(provider, session);
await StartTaskAsync(tools, "Research", "Task 1", "First task");
AIAgent agent = CreateAgent(provider);
var options = new BackgroundTaskCompletionLoopEvaluatorOptions
{
FeedbackMessageTemplate = "Pending:\n" + BackgroundTaskCompletionLoopEvaluator.IncompleteTasksPlaceholder,
};
var evaluator = new BackgroundTaskCompletionLoopEvaluator(options);
LoopContext context = CreateContext(agent, session);
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.True(evaluation.ShouldReinvoke);
Assert.StartsWith("Pending:", evaluation.Feedback);
Assert.Contains("#1", evaluation.Feedback!);
Assert.Contains("First task", evaluation.Feedback!);
// Cleanup.
tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
}
/// <summary>
/// Verify that <see cref="BackgroundAgentsProvider.GetIncompleteTasks"/> returns only the tasks that are still
/// running, excluding completed ones.
/// </summary>
[Fact]
public async Task GetIncompleteTasks_ReturnsOnlyRunningTasksAsync()
{
// Arrange — two tasks, one of which completes.
var tcs1 = new TaskCompletionSource<AgentResponse>();
var tcs2 = new TaskCompletionSource<AgentResponse>();
var agent1 = CreateMockAgentWithRunResult("Research", tcs1.Task);
var agent2 = CreateMockAgentWithRunResult("Writer", tcs2.Task);
var provider = new BackgroundAgentsProvider(new[] { agent1, agent2 });
var session = new ChatClientAgentSession();
IEnumerable<AITool> tools = await CreateToolsForSessionAsync(provider, session);
await StartTaskAsync(tools, "Research", "Task 1", "First task");
await StartTaskAsync(tools, "Writer", "Task 2", "Second task");
// Complete only the first task and wait for it to be finalized.
tcs1.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Result 1")));
await WaitForCompletionAsync(tools, 1);
// Act
IReadOnlyList<BackgroundTaskInfo> incomplete = provider.GetIncompleteTasks(session);
// Assert — only the still-running second task remains.
BackgroundTaskInfo task = Assert.Single(incomplete);
Assert.Equal(2, task.Id);
Assert.Equal("Writer", task.AgentName);
Assert.Equal(BackgroundTaskStatus.Running, task.Status);
// Cleanup.
tcs2.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")));
}
/// <summary>
/// Verify that a task persisted as <see cref="BackgroundTaskStatus.Running"/> but with no corresponding in-flight
/// runtime reference (as happens after a session is serialized and restored) is treated as
/// <see cref="BackgroundTaskStatus.Lost"/> and excluded from the incomplete set, so the loop does not spin forever.
/// </summary>
[Fact]
public async Task GetIncompleteTasks_TaskWithNoInFlightReference_IsTreatedAsLostAndExcludedAsync()
{
// Arrange — seed a Running task into session state without any runtime in-flight reference, simulating a restore.
var backgroundAgent = CreateMockAgent("Research", "Research agent");
var provider = new BackgroundAgentsProvider(new[] { backgroundAgent });
var session = new ChatClientAgentSession();
SeedRunningTaskWithoutInFlight(session, 1, "Research", "First task");
// Act — querying refreshes task state, which finalizes the orphaned task to Lost.
IReadOnlyList<BackgroundTaskInfo> incomplete = provider.GetIncompleteTasks(session);
// Assert — the lost task is not returned, and the evaluator stops rather than looping forever.
Assert.Empty(incomplete);
AIAgent agent = CreateAgent(provider);
var evaluator = new BackgroundTaskCompletionLoopEvaluator();
LoopEvaluation evaluation = await evaluator.EvaluateAsync(CreateContext(agent, session));
Assert.False(evaluation.ShouldReinvoke);
Assert.Null(evaluation.Feedback);
}
private static ChatClientAgent CreateAgent(params AIContextProvider[] providers)
{
var chatClient = new Mock<IChatClient>().Object;
return new ChatClientAgent(chatClient, new ChatClientAgentOptions { AIContextProviders = providers });
}
private static LoopContext CreateContext(AIAgent agent, AgentSession session) => new(
agent,
session,
[new ChatMessage(ChatRole.User, "do the work")],
new AgentResponse([new ChatMessage(ChatRole.Assistant, "in progress")]));
private static async Task<IEnumerable<AITool>> CreateToolsForSessionAsync(BackgroundAgentsProvider provider, AgentSession session)
{
var mockAgent = new Mock<AIAgent>().Object;
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(mockAgent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
return result.Tools!;
}
private static async Task StartTaskAsync(IEnumerable<AITool> tools, string agentName, string input, string description)
{
AIFunction startTask = GetTool(tools, "background_agents_start_task");
await startTask.InvokeAsync(new AIFunctionArguments
{
["agentName"] = agentName,
["input"] = input,
["description"] = description,
});
}
private static async Task WaitForCompletionAsync(IEnumerable<AITool> tools, int taskId)
{
AIFunction waitForFirst = GetTool(tools, "background_agents_wait_for_first_completion");
await waitForFirst.InvokeAsync(new AIFunctionArguments
{
["taskIds"] = new List<int> { taskId },
});
}
private static void SeedRunningTaskWithoutInFlight(AgentSession session, int id, string agentName, string description)
{
var state = new BackgroundAgentState { NextTaskId = id + 1 };
state.Tasks.Add(new BackgroundTaskInfo
{
Id = id,
AgentName = agentName,
Description = description,
Status = BackgroundTaskStatus.Running,
});
// Persist under the BackgroundAgentsProvider's state key with no runtime in-flight entry, mirroring a restored
// session whose in-flight task references have been lost.
session.StateBag.SetValue(nameof(BackgroundAgentsProvider), state, AgentJsonUtilities.DefaultOptions);
}
private static AIAgent CreateMockAgent(string? name, string? description)
{
var mock = new Mock<AIAgent>();
mock.SetupGet(a => a.Name).Returns(name!);
mock.SetupGet(a => a.Description).Returns(description);
return mock.Object;
}
private static AIAgent CreateMockAgentWithRunResult(string name, Task<AgentResponse> result)
{
var mock = new Mock<AIAgent>();
mock.SetupGet(a => a.Name).Returns(name);
mock.Protected()
.Setup<ValueTask<AgentSession>>(
"CreateSessionCoreAsync",
ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask<AgentSession>(new ChatClientAgentSession()));
mock.Protected()
.Setup<Task<AgentResponse>>(
"RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.Returns(result);
return mock.Object;
}
private static AIFunction GetTool(IEnumerable<AITool> tools, string name)
{
return (AIFunction)tools.First(t => t is AIFunction f && f.Name == name);
}
}
@@ -0,0 +1,145 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="CompletionMarkerLoopEvaluator"/> class.
/// </summary>
public class CompletionMarkerLoopEvaluatorTests
{
/// <summary>
/// Verify that the constructor throws when the marker is null, empty, or whitespace.
/// </summary>
/// <param name="marker">The invalid marker value.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void CompletionMarkerLoopEvaluator_InvalidMarker_Throws(string? marker)
{
// Act & Assert
Assert.ThrowsAny<ArgumentException>(() => new CompletionMarkerLoopEvaluator(marker!));
}
/// <summary>
/// Verify that the evaluator stops the loop when the marker appears in the latest response.
/// </summary>
[Fact]
public async Task EvaluateAsync_MarkerPresent_StopsAsync()
{
// Arrange
var evaluator = new CompletionMarkerLoopEvaluator("DONE");
LoopContext context = CreateContext("all DONE here");
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.False(evaluation.ShouldReinvoke);
}
/// <summary>
/// Verify that the evaluator continues with default feedback (containing the marker) when the marker is absent.
/// </summary>
[Fact]
public async Task EvaluateAsync_MarkerAbsent_ContinuesWithDefaultFeedbackAsync()
{
// Arrange
var evaluator = new CompletionMarkerLoopEvaluator("DONE");
LoopContext context = CreateContext("still working");
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.True(evaluation.ShouldReinvoke);
Assert.NotNull(evaluation.Feedback);
Assert.Contains("DONE", evaluation.Feedback!);
Assert.DoesNotContain(CompletionMarkerLoopEvaluator.CompletionMarkerPlaceholder, evaluation.Feedback!);
}
/// <summary>
/// Verify that a custom feedback template is honored, with the completion marker substituted for the placeholder.
/// </summary>
[Fact]
public async Task EvaluateAsync_MarkerAbsent_CustomTemplate_IsHonoredAsync()
{
// Arrange
const string Template = "Keep going and finish with " + CompletionMarkerLoopEvaluator.CompletionMarkerPlaceholder + " when done.";
var evaluator = new CompletionMarkerLoopEvaluator("FINISHED", new CompletionMarkerLoopEvaluatorOptions { FeedbackMessageTemplate = Template });
LoopContext context = CreateContext("still working");
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.True(evaluation.ShouldReinvoke);
Assert.Equal("Keep going and finish with FINISHED when done.", evaluation.Feedback);
}
/// <summary>
/// Verify that a custom feedback template containing the last-response placeholder echoes the agent's latest
/// response text, with no leftover placeholder.
/// </summary>
[Fact]
public async Task EvaluateAsync_MarkerAbsent_CustomTemplate_SubstitutesLastResponseAsync()
{
// Arrange
const string Template = "Your previous attempt was: '" + CompletionMarkerLoopEvaluator.LastResponsePlaceholder +
"'. Improve it and finish with " + CompletionMarkerLoopEvaluator.CompletionMarkerPlaceholder + " when done.";
var evaluator = new CompletionMarkerLoopEvaluator("FINISHED", new CompletionMarkerLoopEvaluatorOptions { FeedbackMessageTemplate = Template });
LoopContext context = CreateContext("candidate name: NoteNest");
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.True(evaluation.ShouldReinvoke);
Assert.Equal("Your previous attempt was: 'candidate name: NoteNest'. Improve it and finish with FINISHED when done.", evaluation.Feedback);
Assert.DoesNotContain(CompletionMarkerLoopEvaluator.LastResponsePlaceholder, evaluation.Feedback!);
}
/// <summary>
/// Verify that the default feedback template does not include the agent's latest response text (the last-response
/// placeholder is opt-in via a custom template).
/// </summary>
[Fact]
public async Task EvaluateAsync_MarkerAbsent_DefaultTemplate_DoesNotIncludeLastResponseAsync()
{
// Arrange
var evaluator = new CompletionMarkerLoopEvaluator("DONE");
LoopContext context = CreateContext("candidate name: NoteNest");
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.True(evaluation.ShouldReinvoke);
Assert.Equal(CompletionMarkerLoopEvaluator.DefaultFeedbackMessageTemplate.Replace(CompletionMarkerLoopEvaluator.CompletionMarkerPlaceholder, "DONE"), evaluation.Feedback);
Assert.DoesNotContain("NoteNest", evaluation.Feedback!);
}
/// <summary>
/// Verify that EvaluateAsync throws when the context is null.
/// </summary>
[Fact]
public async Task EvaluateAsync_NullContext_ThrowsAsync()
{
// Arrange
var evaluator = new CompletionMarkerLoopEvaluator("DONE");
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>("context", async () => await evaluator.EvaluateAsync(null!));
}
private static LoopContext CreateContext(string responseText) => new(
new Mock<AIAgent>().Object,
new ChatClientAgentSession(),
[new ChatMessage(ChatRole.User, "go")],
new AgentResponse([new ChatMessage(ChatRole.Assistant, responseText)]));
}
@@ -0,0 +1,113 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="DelegateLoopEvaluator"/> class.
/// </summary>
public class DelegateLoopEvaluatorTests
{
/// <summary>
/// Verify that the constructor throws when the evaluate delegate is null.
/// </summary>
[Fact]
public void DelegateLoopEvaluator_NullDelegate_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("evaluate", () => new DelegateLoopEvaluator(null!));
}
/// <summary>
/// Verify that EvaluateAsync throws when the context is null.
/// </summary>
[Fact]
public async Task EvaluateAsync_NullContext_ThrowsAsync()
{
// Arrange
var evaluator = new DelegateLoopEvaluator((_, _) => new ValueTask<LoopEvaluation>(LoopEvaluation.Stop()));
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>("context", async () => await evaluator.EvaluateAsync(null!));
}
/// <summary>
/// Verify that EvaluateAsync invokes the supplied delegate and returns the evaluation it produces.
/// </summary>
[Fact]
public async Task EvaluateAsync_InvokesDelegate_AndReturnsItsEvaluationAsync()
{
// Arrange
bool invoked = false;
var expected = LoopEvaluation.Continue("feedback");
var evaluator = new DelegateLoopEvaluator((_, _) =>
{
invoked = true;
return new ValueTask<LoopEvaluation>(expected);
});
LoopContext context = CreateContext();
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.True(invoked);
Assert.Same(expected, evaluation);
}
/// <summary>
/// Verify that EvaluateAsync passes the same context instance to the delegate.
/// </summary>
[Fact]
public async Task EvaluateAsync_PassesContextToDelegateAsync()
{
// Arrange
LoopContext? received = null;
var evaluator = new DelegateLoopEvaluator((ctx, _) =>
{
received = ctx;
return new ValueTask<LoopEvaluation>(LoopEvaluation.Stop());
});
LoopContext context = CreateContext();
// Act
await evaluator.EvaluateAsync(context);
// Assert
Assert.Same(context, received);
}
/// <summary>
/// Verify that EvaluateAsync forwards the cancellation token to the delegate.
/// </summary>
[Fact]
public async Task EvaluateAsync_ForwardsCancellationTokenToDelegateAsync()
{
// Arrange
using var cts = new CancellationTokenSource();
CancellationToken received = default;
var evaluator = new DelegateLoopEvaluator((_, ct) =>
{
received = ct;
return new ValueTask<LoopEvaluation>(LoopEvaluation.Stop());
});
LoopContext context = CreateContext();
// Act
await evaluator.EvaluateAsync(context, cts.Token);
// Assert
Assert.Equal(cts.Token, received);
}
private static LoopContext CreateContext() => new(
new Mock<AIAgent>().Object,
new ChatClientAgentSession(),
[new ChatMessage(ChatRole.User, "go")],
new AgentResponse([new ChatMessage(ChatRole.Assistant, "response")]));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="LoopContext"/> class, including its public constructor used to test custom evaluators.
/// </summary>
public class LoopContextTests
{
/// <summary>
/// Verify that the constructor throws when the agent is null.
/// </summary>
[Fact]
public void Constructor_NullAgent_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("agent", () => new LoopContext(
null!, new ChatClientAgentSession(), [], CreateResponse()));
}
/// <summary>
/// Verify that the constructor throws when the session is null.
/// </summary>
[Fact]
public void Constructor_NullSession_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("session", () => new LoopContext(
new Mock<AIAgent>().Object, null!, [], CreateResponse()));
}
/// <summary>
/// Verify that the constructor throws when the initial messages are null.
/// </summary>
[Fact]
public void Constructor_NullInitialMessages_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("initialMessages", () => new LoopContext(
new Mock<AIAgent>().Object, new ChatClientAgentSession(), null!, CreateResponse()));
}
/// <summary>
/// Verify that the constructor throws when the last response is null.
/// </summary>
[Fact]
public void Constructor_NullLastResponse_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("lastResponse", () => new LoopContext(
new Mock<AIAgent>().Object, new ChatClientAgentSession(), [], null!));
}
/// <summary>
/// Verify that the constructor populates the properties and that LastResponse is never null.
/// </summary>
[Fact]
public void Constructor_ValidArguments_SetsProperties()
{
// Arrange
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
ChatMessage[] initialMessages = [new ChatMessage(ChatRole.User, "go")];
var response = CreateResponse("done");
// Act
var context = new LoopContext(agent, session, initialMessages, response);
// Assert
Assert.Same(agent, context.Agent);
Assert.Same(session, context.Session);
Assert.Same(initialMessages, context.InitialMessages);
Assert.Same(response, context.LastResponse);
Assert.Null(context.RunOptions);
Assert.NotNull(context.AdditionalProperties);
Assert.Equal(0, context.Iteration);
Assert.Empty(context.Feedback);
}
/// <summary>
/// Verify that the session can be replaced through the internal setter (used by the loop for fresh contexts).
/// </summary>
[Fact]
public void Session_IsInternallySettable()
{
// Arrange
var context = new LoopContext(
new Mock<AIAgent>().Object, new ChatClientAgentSession(), [], CreateResponse());
var newSession = new ChatClientAgentSession();
// Act
context.Session = newSession;
// Assert
Assert.Same(newSession, context.Session);
}
/// <summary>
/// Verify that <see cref="LoopContext.Feedback"/> can be assigned through its internal setter.
/// </summary>
[Fact]
public void Feedback_IsInternallySettable()
{
// Arrange
var context = new LoopContext(
new Mock<AIAgent>().Object, new ChatClientAgentSession(), [], CreateResponse());
// Act
context.Feedback = ["first", null];
// Assert
Assert.Equal(["first", null], context.Feedback);
}
/// <summary>
/// Verify that an evaluator can be evaluated against a publicly-constructed context (the scenario the public
/// constructor exists to support).
/// </summary>
[Fact]
public async Task PubliclyConstructedContext_CanEvaluateEvaluatorAsync()
{
// Arrange
var context = new LoopContext(
new Mock<AIAgent>().Object,
new ChatClientAgentSession(),
[new ChatMessage(ChatRole.User, "go")],
CreateResponse("done"));
var evaluator = new DelegateLoopEvaluator((ctx, _) =>
new ValueTask<LoopEvaluation>(
ctx.LastResponse.Text == "done" ? LoopEvaluation.Stop() : LoopEvaluation.Continue()));
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.False(evaluation.ShouldReinvoke);
}
private static AgentResponse CreateResponse(string text = "response") =>
new([new ChatMessage(ChatRole.Assistant, text)]);
}
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="LoopEvaluation"/> class.
/// </summary>
public class LoopEvaluationTests
{
/// <summary>
/// Verify that Stop produces an evaluation that does not re-invoke and carries no feedback.
/// </summary>
[Fact]
public void Stop_DoesNotReinvoke_AndHasNoFeedback()
{
// Act
var evaluation = LoopEvaluation.Stop();
// Assert
Assert.False(evaluation.ShouldReinvoke);
Assert.Null(evaluation.Feedback);
}
/// <summary>
/// Verify that Continue with no argument re-invokes and carries no feedback.
/// </summary>
[Fact]
public void Continue_NoFeedback_ReinvokesWithNullFeedback()
{
// Act
var evaluation = LoopEvaluation.Continue();
// Assert
Assert.True(evaluation.ShouldReinvoke);
Assert.Null(evaluation.Feedback);
}
/// <summary>
/// Verify that Continue with whitespace-only feedback normalizes the feedback to null, matching the documented
/// "null, empty, or whitespace is treated as no feedback" semantics.
/// </summary>
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t\n")]
public void Continue_WhitespaceFeedback_NormalizesToNull(string feedback)
{
// Act
var evaluation = LoopEvaluation.Continue(feedback);
// Assert
Assert.True(evaluation.ShouldReinvoke);
Assert.Null(evaluation.Feedback);
}
}
@@ -0,0 +1,141 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Shared helpers used by the LoopAgent and LoopEvaluator unit tests.
/// </summary>
internal static class LoopTestHelpers
{
/// <summary>
/// Creates a <see cref="DelegateLoopEvaluator"/> that re-invokes the agent (without feedback) while the
/// supplied predicate returns <see langword="true"/>.
/// </summary>
public static DelegateLoopEvaluator While(Func<LoopContext, bool> shouldReinvoke) =>
new((context, _) =>
new ValueTask<LoopEvaluation>(
shouldReinvoke(context) ? LoopEvaluation.Continue() : LoopEvaluation.Stop()));
/// <summary>
/// Creates a mocked judge <see cref="IChatClient"/> that always returns the supplied response text.
/// </summary>
public static IChatClient CreateJudgeClient(string responseText)
{
var mock = new Mock<IChatClient>();
mock.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, responseText)));
return mock.Object;
}
/// <summary>
/// Creates a mocked judge <see cref="IChatClient"/> that always returns the supplied response text and captures the
/// messages it was invoked with via <paramref name="capturedMessages"/>.
/// </summary>
public static IChatClient CreateCapturingJudgeClient(string responseText, out List<ChatMessage> capturedMessages)
{
var captured = new List<ChatMessage>();
capturedMessages = captured;
var mock = new Mock<IChatClient>();
mock.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((messages, _, _) =>
{
captured.Clear();
captured.AddRange(messages);
})
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, responseText)));
return mock.Object;
}
public 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();
}
}
}
/// <summary>
/// Captures the messages sent to a mocked non-streaming inner agent and produces responses by call index.
/// </summary>
internal sealed class InnerAgentCapture
{
public InnerAgentCapture(Func<int, AgentResponse> responseFactory)
{
this.Mock
.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, session, _, _) =>
{
this.CallCount++;
this.MessagesPerCall.Add(msgs.ToList());
this.SessionsPerCall.Add(session);
})
.ReturnsAsync(() => responseFactory(this.CallCount));
}
public Mock<AIAgent> Mock { get; } = new();
public AIAgent Agent => this.Mock.Object;
public int CallCount { get; private set; }
public List<List<ChatMessage>> MessagesPerCall { get; } = [];
public List<AgentSession?> SessionsPerCall { get; } = [];
}
/// <summary>
/// Captures the messages sent to a mocked streaming inner agent and produces updates by call index.
/// </summary>
internal sealed class InnerStreamingCapture
{
public InnerStreamingCapture(Func<int, AgentResponseUpdate[]> updatesFactory)
{
this.Mock
.Protected()
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, ct) =>
{
this.CallCount++;
this.MessagesPerCall.Add(msgs.ToList());
return LoopTestHelpers.ToAsyncEnumerableAsync(updatesFactory(this.CallCount), ct);
});
}
public Mock<AIAgent> Mock { get; } = new();
public AIAgent Agent => this.Mock.Object;
public int CallCount { get; private set; }
public List<List<ChatMessage>> MessagesPerCall { get; } = [];
}
@@ -0,0 +1,259 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="TodoCompletionLoopEvaluator"/> class.
/// </summary>
public class TodoCompletionLoopEvaluatorTests
{
/// <summary>
/// Verify that the constructor throws when a non-null but empty modes collection is supplied.
/// </summary>
[Fact]
public void TodoCompletionLoopEvaluator_EmptyModes_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = [] }));
}
/// <summary>
/// Verify that the constructor throws when a mode name is null, empty, or whitespace.
/// </summary>
/// <param name="mode">The invalid mode name.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void TodoCompletionLoopEvaluator_InvalidModeName_Throws(string? mode)
{
// Act & Assert
Assert.Throws<ArgumentException>(() => new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = [mode!] }));
}
/// <summary>
/// Verify that the constructor succeeds with null modes (applies in every mode) and with a valid mode set.
/// </summary>
[Fact]
public void TodoCompletionLoopEvaluator_ValidConstruction_Succeeds()
{
// Act & Assert
_ = new TodoCompletionLoopEvaluator();
_ = new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = ["execute"] });
}
/// <summary>
/// Verify that evaluation throws when no <see cref="TodoProvider"/> can be resolved from the agent.
/// </summary>
[Fact]
public async Task EvaluateAsync_NoTodoProvider_ThrowsAsync()
{
// Arrange — a bare agent that resolves no providers.
var evaluator = new TodoCompletionLoopEvaluator();
var context = CreateContext(new Mock<AIAgent>().Object, new ChatClientAgentSession());
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () => await evaluator.EvaluateAsync(context));
}
/// <summary>
/// Verify that evaluation throws when modes are configured but no <see cref="AgentModeProvider"/> can be resolved.
/// </summary>
[Fact]
public async Task EvaluateAsync_ModesConfiguredButNoModeProvider_ThrowsAsync()
{
// Arrange — agent has a TodoProvider but no AgentModeProvider.
var todoProvider = new TodoProvider();
var session = new ChatClientAgentSession();
SeedTodos(session, (1, "Task one", null, false));
AIAgent agent = CreateAgent(todoProvider);
var evaluator = new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = ["execute"] });
LoopContext context = CreateContext(agent, session);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () => await evaluator.EvaluateAsync(context));
}
/// <summary>
/// Verify that, with no modes configured, the evaluator continues while incomplete todos remain and the feedback
/// lists those todos.
/// </summary>
[Fact]
public async Task EvaluateAsync_NoModes_RemainingTodos_ContinuesWithFeedbackAsync()
{
// Arrange
var todoProvider = new TodoProvider();
var session = new ChatClientAgentSession();
SeedTodos(session, (1, "Write code", null, false), (2, "Add tests", "cover edge cases", false), (3, "Done item", null, true));
AIAgent agent = CreateAgent(todoProvider);
var evaluator = new TodoCompletionLoopEvaluator();
LoopContext context = CreateContext(agent, session);
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.True(evaluation.ShouldReinvoke);
Assert.NotNull(evaluation.Feedback);
Assert.Contains("Write code", evaluation.Feedback!);
Assert.Contains("Add tests", evaluation.Feedback!);
Assert.Contains("cover edge cases", evaluation.Feedback!);
// Completed items must not appear in the feedback list.
Assert.DoesNotContain("Done item", evaluation.Feedback!);
}
/// <summary>
/// Verify that, with no modes configured, the evaluator stops when there are no incomplete todos.
/// </summary>
[Fact]
public async Task EvaluateAsync_NoModes_NoRemainingTodos_StopsAsync()
{
// Arrange
var todoProvider = new TodoProvider();
var session = new ChatClientAgentSession();
SeedTodos(session, (1, "Completed", null, true));
AIAgent agent = CreateAgent(todoProvider);
var evaluator = new TodoCompletionLoopEvaluator();
LoopContext context = CreateContext(agent, session);
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.False(evaluation.ShouldReinvoke);
}
/// <summary>
/// Verify that, when the current mode is one of the configured modes and incomplete todos remain, the evaluator
/// continues.
/// </summary>
[Fact]
public async Task EvaluateAsync_ModeMatches_RemainingTodos_ContinuesAsync()
{
// Arrange
var todoProvider = new TodoProvider();
var modeProvider = new AgentModeProvider();
var session = new ChatClientAgentSession();
modeProvider.SetMode(session, "execute");
SeedTodos(session, (1, "Work item", null, false));
AIAgent agent = CreateAgent(todoProvider, modeProvider);
var evaluator = new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = ["execute"] });
LoopContext context = CreateContext(agent, session);
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.True(evaluation.ShouldReinvoke);
}
/// <summary>
/// Verify that, when the current mode is one of the configured modes but no incomplete todos remain, the evaluator
/// stops.
/// </summary>
[Fact]
public async Task EvaluateAsync_ModeMatches_NoRemainingTodos_StopsAsync()
{
// Arrange
var todoProvider = new TodoProvider();
var modeProvider = new AgentModeProvider();
var session = new ChatClientAgentSession();
modeProvider.SetMode(session, "execute");
SeedTodos(session, (1, "Already done", null, true));
AIAgent agent = CreateAgent(todoProvider, modeProvider);
var evaluator = new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = ["execute"] });
LoopContext context = CreateContext(agent, session);
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.False(evaluation.ShouldReinvoke);
}
/// <summary>
/// Verify that, when the current mode is not one of the configured modes, the evaluator stops even if incomplete
/// todos remain.
/// </summary>
[Fact]
public async Task EvaluateAsync_ModeDoesNotMatch_StopsEvenWithRemainingTodosAsync()
{
// Arrange
var todoProvider = new TodoProvider();
var modeProvider = new AgentModeProvider();
var session = new ChatClientAgentSession();
modeProvider.SetMode(session, "plan");
SeedTodos(session, (1, "Still open", null, false));
AIAgent agent = CreateAgent(todoProvider, modeProvider);
var evaluator = new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = ["execute"] });
LoopContext context = CreateContext(agent, session);
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.False(evaluation.ShouldReinvoke);
}
/// <summary>
/// Verify that a custom feedback template with the remaining-todos placeholder is honored.
/// </summary>
[Fact]
public async Task EvaluateAsync_CustomTemplate_IsHonoredAsync()
{
// Arrange
var todoProvider = new TodoProvider();
var session = new ChatClientAgentSession();
SeedTodos(session, (1, "Remaining task", null, false));
AIAgent agent = CreateAgent(todoProvider);
var options = new TodoCompletionLoopEvaluatorOptions
{
FeedbackMessageTemplate = "Keep going. Open:\n" + TodoCompletionLoopEvaluator.RemainingTodosPlaceholder,
};
var evaluator = new TodoCompletionLoopEvaluator(options: options);
LoopContext context = CreateContext(agent, session);
// Act
LoopEvaluation evaluation = await evaluator.EvaluateAsync(context);
// Assert
Assert.True(evaluation.ShouldReinvoke);
Assert.StartsWith("Keep going. Open:", evaluation.Feedback);
Assert.Contains("Remaining task", evaluation.Feedback!);
}
private static ChatClientAgent CreateAgent(params AIContextProvider[] providers)
{
var chatClient = new Mock<IChatClient>().Object;
return new ChatClientAgent(chatClient, new ChatClientAgentOptions { AIContextProviders = providers });
}
private static LoopContext CreateContext(AIAgent agent, AgentSession session) => new(
agent,
session,
[new ChatMessage(ChatRole.User, "do the work")],
new AgentResponse([new ChatMessage(ChatRole.Assistant, "in progress")]));
private static void SeedTodos(AgentSession session, params (int Id, string Title, string? Description, bool IsComplete)[] items)
{
var state = new TodoState { NextId = items.Length + 1 };
foreach ((int id, string title, string? description, bool isComplete) in items)
{
state.Items.Add(new TodoItem
{
Id = id,
Title = title,
Description = description,
IsComplete = isComplete,
});
}
// Persist under the TodoProvider's state key so the provider reads it back via GetRemainingTodosAsync.
session.StateBag.SetValue(nameof(TodoProvider), state, AgentJsonUtilities.DefaultOptions);
}
}
@@ -0,0 +1,804 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="TodoProvider"/> class.
/// </summary>
public class TodoProviderTests
{
#region ProvideAIContextAsync Tests
/// <summary>
/// Verify that the provider returns tools and instructions.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_ReturnsToolsAndInstructionsAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.NotNull(result.Instructions);
Assert.NotNull(result.Tools);
Assert.Equal(5, result.Tools!.Count());
}
#endregion
#region AddTodos Tests
/// <summary>
/// Verify that AddTodos creates a new todo item when given a single item.
/// </summary>
[Fact]
public async Task AddTodos_CreatesSingleItemAsync()
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
// Act
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "Test todo", Description = "A test description" } },
});
// Assert
Assert.Single(state.Items);
Assert.Equal("Test todo", state.Items[0].Title);
Assert.Equal("A test description", state.Items[0].Description);
Assert.False(state.Items[0].IsComplete);
Assert.Equal(1, state.Items[0].Id);
}
/// <summary>
/// Verify that AddTodos creates multiple items with incrementing IDs.
/// </summary>
[Fact]
public async Task AddTodos_CreatesMultipleItemsWithIncrementingIdsAsync()
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
// Act
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput>
{
new() { Title = "First", Description = null },
new() { Title = "Second", Description = null },
new() { Title = "Third", Description = "With description" },
},
});
// Assert
Assert.Equal(3, state.Items.Count);
Assert.Equal(1, state.Items[0].Id);
Assert.Equal("First", state.Items[0].Title);
Assert.Equal(2, state.Items[1].Id);
Assert.Equal("Second", state.Items[1].Title);
Assert.Equal(3, state.Items[2].Id);
Assert.Equal("Third", state.Items[2].Title);
Assert.Equal("With description", state.Items[2].Description);
}
#endregion
#region CompleteTodos Tests
/// <summary>
/// Verify that CompleteTodos marks an item as complete.
/// </summary>
[Fact]
public async Task CompleteTodos_MarksItemCompleteAsync()
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
AIFunction completeTodos = GetTool(tools, "todos_complete");
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Test", Description = null } } });
// Act
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" } } });
// Assert
Assert.True(state.Items[0].IsComplete);
Assert.Equal(1, GetIntResult(result));
}
/// <summary>
/// Verify that CompleteTodos marks multiple items as complete.
/// </summary>
[Fact]
public async Task CompleteTodos_MarksMultipleItemsCompleteAsync()
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
AIFunction completeTodos = GetTool(tools, "todos_complete");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } },
});
// Act
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" }, new() { Id = 3, Reason = "Done" } } });
// Assert
Assert.True(state.Items[0].IsComplete);
Assert.False(state.Items[1].IsComplete);
Assert.True(state.Items[2].IsComplete);
Assert.Equal(2, GetIntResult(result));
}
/// <summary>
/// Verify that CompleteTodos returns zero for non-existent IDs.
/// </summary>
[Fact]
public async Task CompleteTodos_ReturnsZeroForMissingIdsAsync()
{
// Arrange
var (tools, _) = await CreateToolsWithStateAsync();
AIFunction completeTodos = GetTool(tools, "todos_complete");
// Act
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 999, Reason = "Done" } } });
// Assert
Assert.Equal(0, GetIntResult(result));
}
/// <summary>
/// Verify that CompleteTodos accepts an optional reason parameter.
/// </summary>
[Fact]
public async Task CompleteTodos_AcceptsReasonParameterAsync()
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
AIFunction completeTodos = GetTool(tools, "todos_complete");
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Research topic" } } });
// Act
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments()
{
["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Found the answer in the documentation." } },
});
// Assert
Assert.True(state.Items[0].IsComplete);
Assert.Equal(1, GetIntResult(result));
}
#endregion
#region RemoveTodos Tests
/// <summary>
/// Verify that RemoveTodos removes an item.
/// </summary>
[Fact]
public async Task RemoveTodos_RemovesItemAsync()
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
AIFunction removeTodos = GetTool(tools, "todos_remove");
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Test", Description = null } } });
// Act
object? result = await removeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1 } });
// Assert
Assert.Empty(state.Items);
Assert.Equal(1, GetIntResult(result));
}
/// <summary>
/// Verify that RemoveTodos removes multiple items.
/// </summary>
[Fact]
public async Task RemoveTodos_RemovesMultipleItemsAsync()
{
// Arrange
var (tools, state) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
AIFunction removeTodos = GetTool(tools, "todos_remove");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } },
});
// Act
object? result = await removeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1, 3 } });
// Assert
Assert.Single(state.Items);
Assert.Equal("Second", state.Items[0].Title);
Assert.Equal(2, GetIntResult(result));
}
/// <summary>
/// Verify that RemoveTodos returns zero for non-existent IDs.
/// </summary>
[Fact]
public async Task RemoveTodos_ReturnsZeroForMissingIdsAsync()
{
// Arrange
var (tools, _) = await CreateToolsWithStateAsync();
AIFunction removeTodos = GetTool(tools, "todos_remove");
// Act
object? result = await removeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 999 } });
// Assert
Assert.Equal(0, GetIntResult(result));
}
#endregion
#region GetRemainingTodos Tests
/// <summary>
/// Verify that GetRemainingTodos returns only incomplete items.
/// </summary>
[Fact]
public async Task GetRemainingTodos_ReturnsOnlyIncompleteAsync()
{
// Arrange
var (tools, _) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
AIFunction completeTodos = GetTool(tools, "todos_complete");
AIFunction getRemainingTodos = GetTool(tools, "todos_get_remaining");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
});
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" } } });
// Act
object? result = await getRemainingTodos.InvokeAsync(new AIFunctionArguments());
// Assert
var remaining = GetArrayResult(result);
Assert.Single(remaining);
Assert.Equal("Pending", remaining[0].GetProperty("title").GetString());
}
#endregion
#region GetAllTodos Tests
/// <summary>
/// Verify that GetAllTodos returns all items.
/// </summary>
[Fact]
public async Task GetAllTodos_ReturnsAllItemsAsync()
{
// Arrange
var (tools, _) = await CreateToolsWithStateAsync();
AIFunction addTodos = GetTool(tools, "todos_add");
AIFunction completeTodos = GetTool(tools, "todos_complete");
AIFunction getAllTodos = GetTool(tools, "todos_get_all");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
});
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" } } });
// Act
object? result = await getAllTodos.InvokeAsync(new AIFunctionArguments());
// Assert
var all = GetArrayResult(result);
Assert.Equal(2, all.Count);
}
#endregion
#region State Persistence Tests
/// <summary>
/// Verify that state persists in the session StateBag.
/// </summary>
[Fact]
public async Task State_PersistsInSessionStateBagAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act — first invocation adds a todo
AIContext result1 = await provider.InvokingAsync(context);
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Persisted", Description = null } } });
// Second invocation should see the same state
AIContext result2 = await provider.InvokingAsync(context);
AIFunction getAllTodos = (AIFunction)result2.Tools!.First(t => t is AIFunction f && f.Name == "todos_get_all");
object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments());
// Assert
var all = GetArrayResult(allResult);
Assert.Single(all);
Assert.Equal("Persisted", all[0].GetProperty("title").GetString());
}
#endregion
#region Public Helper Method Tests
/// <summary>
/// Verify that GetAllTodosAsync returns all items after adding via tools.
/// </summary>
[Fact]
public async Task PublicGetAllTodos_ReturnsAllItemsAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "First", Description = null }, new() { Title = "Second", Description = null } },
});
// Act
var todos = await provider.GetAllTodosAsync(session);
// Assert
Assert.Equal(2, todos.Count);
Assert.Equal("First", todos[0].Title);
Assert.Equal("Second", todos[1].Title);
}
/// <summary>
/// Verify that GetRemainingTodosAsync returns only incomplete items.
/// </summary>
[Fact]
public async Task PublicGetRemainingTodos_ReturnsOnlyIncompleteAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
AIFunction completeTodos = GetTool(result.Tools!, "todos_complete");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
});
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" } } });
// Act
var remaining = await provider.GetRemainingTodosAsync(session);
// Assert
Assert.Single(remaining);
Assert.Equal("Pending", remaining[0].Title);
}
/// <summary>
/// Verify that GetAllTodosAsync returns empty list for a new session.
/// </summary>
[Fact]
public async Task PublicGetAllTodos_ReturnsEmptyForNewSessionAsync()
{
// Arrange
var provider = new TodoProvider();
var session = new ChatClientAgentSession();
// Act
var todos = await provider.GetAllTodosAsync(session);
// Assert
Assert.Empty(todos);
}
#endregion
#region Helper Methods
private static async Task<(IEnumerable<AITool> Tools, TodoState State)> CreateToolsWithStateAsync()
{
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
// Retrieve the state from the session to verify mutations
session.StateBag.TryGetValue<TodoState>("TodoProvider", out var state, AgentJsonUtilities.DefaultOptions);
return (result.Tools!, state!);
}
private static AIFunction GetTool(IEnumerable<AITool> tools, string name)
{
return (AIFunction)tools.First(t => t is AIFunction f && f.Name == name);
}
private static int GetIntResult(object? result)
{
var element = Assert.IsType<JsonElement>(result);
return element.GetInt32();
}
private static List<JsonElement> GetArrayResult(object? result)
{
var element = Assert.IsType<JsonElement>(result);
return element.EnumerateArray().ToList();
}
#endregion
#region Options Tests
/// <summary>
/// Verify that custom instructions override the default.
/// </summary>
[Fact]
public async Task Options_CustomInstructions_OverridesDefaultAsync()
{
// Arrange
var options = new TodoProviderOptions { Instructions = "Custom todo instructions." };
var provider = new TodoProvider(options);
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.Equal("Custom todo instructions.", result.Instructions);
}
/// <summary>
/// Verify that null options uses default instructions.
/// </summary>
[Fact]
public async Task Options_Null_UsesDefaultInstructionsAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.Contains("todo list", result.Instructions);
}
#endregion
#region Message Injection Tests
/// <summary>
/// Verify that ProvideAIContextAsync injects a "none yet" message when the list is empty.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_InjectsEmptyTodoMessageAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.NotNull(result.Messages);
var messages = result.Messages!.ToList();
Assert.Single(messages);
Assert.Contains("none yet", messages[0].Text);
Assert.Contains("### Current todo list", messages[0].Text);
}
/// <summary>
/// Verify that ProvideAIContextAsync injects a message listing existing todos with status.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_InjectsTodoListMessageAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// First invocation — add some todos (one with a description to cover that branch)
AIContext result1 = await provider.InvokingAsync(context);
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
AIFunction completeTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_complete");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput>
{
new() { Title = "First" },
new() { Title = "Second", Description = "Has details" },
},
});
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" } } });
// Act — second invocation should see the updated list in messages
AIContext result2 = await provider.InvokingAsync(context);
// Assert
Assert.NotNull(result2.Messages);
var messages = result2.Messages!.ToList();
Assert.Single(messages);
string text = messages[0].Text!;
Assert.Contains("### Current todo list", text);
Assert.Contains("[done] First", text);
Assert.Contains("[open] Second", text);
Assert.Contains(": Has details", text);
}
/// <summary>
/// Verify that when SuppressTodoListMessage is true, no message is injected.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_SuppressTodoListMessage_NoMessageInjectedAsync()
{
// Arrange
var provider = new TodoProvider(new TodoProviderOptions { SuppressTodoListMessage = true });
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.Null(result.Messages);
}
/// <summary>
/// Verify that a custom TodoListMessageBuilder is used when provided.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_CustomTodoListMessageBuilder_UsesCustomFormatterAsync()
{
// Arrange
var provider = new TodoProvider(new TodoProviderOptions
{
TodoListMessageBuilder = items => $"Custom: {items.Count} items",
});
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// First invocation — add a todo
AIContext result1 = await provider.InvokingAsync(context);
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "Task A" } },
});
// Act — second invocation should use the custom builder
AIContext result2 = await provider.InvokingAsync(context);
// Assert
Assert.NotNull(result2.Messages);
var messages = result2.Messages!.ToList();
Assert.Single(messages);
Assert.Equal("Custom: 1 items", messages[0].Text);
}
/// <summary>
/// Verify that SuppressTodoListMessage takes precedence over a set TodoListMessageBuilder.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_SuppressWinsOverBuilder_NoMessageInjectedAsync()
{
// Arrange
var provider = new TodoProvider(new TodoProviderOptions
{
SuppressTodoListMessage = true,
TodoListMessageBuilder = items => "Should not appear",
});
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.Null(result.Messages);
}
/// <summary>
/// Verify that the list passed to TodoListMessageBuilder is a snapshot and mutating it does not affect state.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_BuilderReceivesSnapshot_MutationDoesNotAffectStateAsync()
{
// Arrange
IReadOnlyList<TodoItem>? capturedList = null;
var provider = new TodoProvider(new TodoProviderOptions
{
TodoListMessageBuilder = items =>
{
capturedList = items;
return "snapshot test";
},
});
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Add a todo
AIContext result1 = await provider.InvokingAsync(context);
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "Original" } },
});
// Act — invoke again to trigger builder with 1 item
await provider.InvokingAsync(context);
// Mutate the captured snapshot
Assert.NotNull(capturedList);
var mutableList = (List<TodoItem>)capturedList!;
mutableList.Clear();
// Assert — provider state is unaffected
var allTodos = await provider.GetAllTodosAsync(session);
Assert.Single(allTodos);
Assert.Equal("Original", allTodos[0].Title);
}
#endregion
#region Concurrency Tests
/// <summary>
/// Verify that concurrent add operations do not produce duplicate IDs.
/// </summary>
[Fact]
public async Task ConcurrentAdds_ProduceUniqueIdsAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
AIFunction getAllTodos = GetTool(result.Tools!, "todos_get_all");
// Act — launch multiple concurrent adds
var tasks = Enumerable.Range(0, 10).Select(i =>
addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = $"Item {i}" } },
}).AsTask());
await Task.WhenAll(tasks);
// Assert — all IDs are unique and sequential
object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments());
var all = GetArrayResult(allResult);
Assert.Equal(10, all.Count);
#pragma warning disable RCS1077 // Optimize LINQ method call — .Order() not available on net472
var ids = all.Select(e => e.GetProperty("id").GetInt32()).OrderBy(x => x).ToList();
#pragma warning restore RCS1077
Assert.Equal(Enumerable.Range(1, 10).ToList(), ids);
}
/// <summary>
/// Verify that concurrent add and complete operations serialize correctly.
/// </summary>
[Fact]
public async Task ConcurrentAddAndComplete_SerializesCorrectlyAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
AIFunction completeTodos = GetTool(result.Tools!, "todos_complete");
AIFunction getAllTodos = GetTool(result.Tools!, "todos_get_all");
// Add initial items
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput>
{
new() { Title = "Existing 1" },
new() { Title = "Existing 2" },
new() { Title = "Existing 3" },
},
});
// Act — concurrent adds and completions
await Task.WhenAll(
addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "New A" }, new() { Title = "New B" } },
}).AsTask(),
addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "New C" } },
}).AsTask(),
completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 1, Reason = "Done" }, new() { Id = 2, Reason = "Done" }, new() { Id = 3, Reason = "Done" } } }).AsTask());
// Assert
object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments());
var all = GetArrayResult(allResult);
Assert.Equal(6, all.Count);
#pragma warning disable RCS1077 // Optimize LINQ method call — .Order() not available on net472
var ids = all.Select(e => e.GetProperty("id").GetInt32()).OrderBy(x => x).ToList();
#pragma warning restore RCS1077
Assert.Equal(ids.Count, ids.Distinct().Count()); // no duplicates
Assert.Equal(Enumerable.Range(1, 6).ToList(), ids);
var completedIds = all.Where(e => e.GetProperty("isComplete").GetBoolean()).Select(e => e.GetProperty("id").GetInt32()).ToHashSet();
Assert.Subset(new HashSet<int> { 1, 2, 3 }, completedIds);
}
#endregion
}
@@ -0,0 +1,288 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AlwaysApproveToolApprovalResponseContent"/> class
/// and <see cref="ToolApprovalRequestContentExtensions"/> extension methods.
/// </summary>
public class AlwaysApproveToolApprovalResponseContentTests
{
#region CreateAlwaysApproveToolResponse
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse sets AlwaysApproveTool to true.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_AlwaysApproveTool_IsTrue()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolResponse();
// Assert
Assert.True(result.AlwaysApproveTool);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse sets AlwaysApproveToolWithArguments to false.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_AlwaysApproveToolWithArguments_IsFalse()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolResponse();
// Assert
Assert.False(result.AlwaysApproveToolWithArguments);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse creates an approved inner response.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_InnerResponse_IsApproved()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolResponse();
// Assert
Assert.True(result.InnerResponse.Approved);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse forwards the reason.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_Reason_IsForwarded()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolResponse("User trusts this tool");
// Assert
Assert.Equal("User trusts this tool", result.InnerResponse.Reason);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse preserves the request ID.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_RequestId_IsPreserved()
{
// Arrange
var request = CreateRequest("MyTool", "custom-request-id");
// Act
var result = request.CreateAlwaysApproveToolResponse();
// Assert
Assert.Equal("custom-request-id", result.InnerResponse.RequestId);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse preserves the tool call.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_ToolCall_IsPreserved()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolResponse();
// Assert
var functionCall = Assert.IsType<FunctionCallContent>(result.InnerResponse.ToolCall);
Assert.Equal("MyTool", functionCall.Name);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse with null reason sets reason to null.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_NullReason_ReasonIsNull()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolResponse();
// Assert
Assert.Null(result.InnerResponse.Reason);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolResponse throws on null request.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolResponse_NullRequest_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("request",
() => ((ToolApprovalRequestContent)null!).CreateAlwaysApproveToolResponse());
}
#endregion
#region CreateAlwaysApproveToolWithArgumentsResponse
/// <summary>
/// Verify that CreateAlwaysApproveToolWithArgumentsResponse sets AlwaysApproveToolWithArguments to true.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolWithArgumentsResponse_AlwaysApproveToolWithArguments_IsTrue()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolWithArgumentsResponse();
// Assert
Assert.True(result.AlwaysApproveToolWithArguments);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolWithArgumentsResponse sets AlwaysApproveTool to false.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolWithArgumentsResponse_AlwaysApproveTool_IsFalse()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolWithArgumentsResponse();
// Assert
Assert.False(result.AlwaysApproveTool);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolWithArgumentsResponse creates an approved inner response.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolWithArgumentsResponse_InnerResponse_IsApproved()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolWithArgumentsResponse();
// Assert
Assert.True(result.InnerResponse.Approved);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolWithArgumentsResponse forwards the reason.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolWithArgumentsResponse_Reason_IsForwarded()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolWithArgumentsResponse("Specific approval");
// Assert
Assert.Equal("Specific approval", result.InnerResponse.Reason);
}
/// <summary>
/// Verify that CreateAlwaysApproveToolWithArgumentsResponse throws on null request.
/// </summary>
[Fact]
public void CreateAlwaysApproveToolWithArgumentsResponse_NullRequest_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("request",
() => ((ToolApprovalRequestContent)null!).CreateAlwaysApproveToolWithArgumentsResponse());
}
#endregion
#region AlwaysApproveToolApprovalResponseContent Properties
/// <summary>
/// Verify that the content is an AIContent subclass.
/// </summary>
[Fact]
public void Content_IsAIContentSubclass()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var result = request.CreateAlwaysApproveToolResponse();
// Assert
Assert.IsAssignableFrom<AIContent>(result);
}
/// <summary>
/// Verify that InnerResponse preserves tool call arguments.
/// </summary>
[Fact]
public void InnerResponse_PreservesArguments()
{
// Arrange
var args = new Dictionary<string, object?> { ["path"] = "test.txt", ["count"] = 5 };
var request = new ToolApprovalRequestContent("req1",
new FunctionCallContent("call1", "ReadFile", args));
// Act
var result = request.CreateAlwaysApproveToolWithArgumentsResponse();
// Assert
var functionCall = Assert.IsType<FunctionCallContent>(result.InnerResponse.ToolCall);
Assert.Equal(2, functionCall.Arguments!.Count);
Assert.Equal("test.txt", functionCall.Arguments["path"]);
}
/// <summary>
/// Verify that both factory methods produce distinct instances from the same request.
/// </summary>
[Fact]
public void FactoryMethods_ProduceDistinctInstances()
{
// Arrange
var request = CreateRequest("MyTool");
// Act
var toolLevel = request.CreateAlwaysApproveToolResponse();
var argsLevel = request.CreateAlwaysApproveToolWithArgumentsResponse();
// Assert
Assert.NotSame(toolLevel, argsLevel);
Assert.NotSame(toolLevel.InnerResponse, argsLevel.InnerResponse);
}
#endregion
#region Helpers
private static ToolApprovalRequestContent CreateRequest(string toolName, string requestId = "req1")
{
return new ToolApprovalRequestContent(requestId, new FunctionCallContent("call1", toolName));
}
#endregion
}
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="ToolApprovalAgentBuilderExtensions"/> class.
/// </summary>
public class ToolApprovalAgentBuilderExtensionsTests
{
/// <summary>
/// Verify that UseToolApproval throws ArgumentNullException when builder is null.
/// </summary>
[Fact]
public void UseToolApproval_WithNullBuilder_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("builder", () => ((AIAgentBuilder)null!).UseToolApproval());
}
/// <summary>
/// Verify that UseToolApproval returns a ToolApprovalAgent.
/// </summary>
[Fact]
public void UseToolApproval_WithValidBuilder_ReturnsToolApprovalAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.UseToolApproval().Build();
// Assert
Assert.IsType<ToolApprovalAgent>(result);
}
/// <summary>
/// Verify that UseToolApproval returns the same builder instance for chaining.
/// </summary>
[Fact]
public void UseToolApproval_ReturnsBuilderForChaining()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.UseToolApproval();
// Assert
Assert.Same(builder, result);
}
/// <summary>
/// Verify that UseToolApproval with custom JsonSerializerOptions works correctly.
/// </summary>
[Fact]
public void UseToolApproval_WithCustomOptions_ReturnsToolApprovalAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
var options = new ToolApprovalAgentOptions { JsonSerializerOptions = new JsonSerializerOptions() };
// Act
var result = builder.UseToolApproval(options: options).Build();
// Assert
Assert.IsType<ToolApprovalAgent>(result);
}
}
@@ -0,0 +1,180 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="ToolApprovalRule"/> class.
/// </summary>
public class ToolApprovalRuleTests
{
#region Construction and Defaults
/// <summary>
/// Verify that a new rule has the expected default values.
/// </summary>
[Fact]
public void NewRule_HasDefaultValues()
{
// Act
var rule = new ToolApprovalRule();
// Assert
Assert.Equal(string.Empty, rule.ToolName);
Assert.Null(rule.Arguments);
}
/// <summary>
/// Verify that ToolName can be set.
/// </summary>
[Fact]
public void ToolName_CanBeSet()
{
// Arrange & Act
var rule = new ToolApprovalRule { ToolName = "ReadFile" };
// Assert
Assert.Equal("ReadFile", rule.ToolName);
}
/// <summary>
/// Verify that Arguments can be set.
/// </summary>
[Fact]
public void Arguments_CanBeSet()
{
// Arrange & Act
var args = new Dictionary<string, string> { ["path"] = "test.txt" };
var rule = new ToolApprovalRule { ToolName = "ReadFile", Arguments = args };
// Assert
Assert.NotNull(rule.Arguments);
Assert.Equal("test.txt", rule.Arguments["path"]);
}
#endregion
#region JSON Serialization
/// <summary>
/// Verify that a tool-level rule round-trips through JSON serialization.
/// </summary>
[Fact]
public void Serialize_ToolLevelRule_RoundTrips()
{
// Arrange
var rule = new ToolApprovalRule { ToolName = "MyTool" };
// Act
var json = JsonSerializer.Serialize(rule, AgentJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ToolApprovalRule>(json, AgentJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
Assert.Equal("MyTool", deserialized!.ToolName);
Assert.Null(deserialized.Arguments);
}
/// <summary>
/// Verify that a tool+arguments rule round-trips through JSON serialization.
/// </summary>
[Fact]
public void Serialize_ToolWithArgsRule_RoundTrips()
{
// Arrange
var rule = new ToolApprovalRule
{
ToolName = "ReadFile",
Arguments = new Dictionary<string, string> { ["path"] = "test.txt", ["encoding"] = "utf-8" },
};
// Act
var json = JsonSerializer.Serialize(rule, AgentJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ToolApprovalRule>(json, AgentJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
Assert.Equal("ReadFile", deserialized!.ToolName);
Assert.NotNull(deserialized.Arguments);
Assert.Equal(2, deserialized.Arguments!.Count);
Assert.Equal("test.txt", deserialized.Arguments["path"]);
Assert.Equal("utf-8", deserialized.Arguments["encoding"]);
}
/// <summary>
/// Verify that an empty-arguments rule round-trips as a non-null empty dictionary, keeping it
/// distinct from a tool-level (null arguments) rule so persisted session state preserves the
/// narrower scope.
/// </summary>
[Fact]
public void Serialize_EmptyArgsRule_RoundTrips()
{
// Arrange
var rule = new ToolApprovalRule
{
ToolName = "SendPayment",
Arguments = new Dictionary<string, string>(),
};
// Act
var json = JsonSerializer.Serialize(rule, AgentJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ToolApprovalRule>(json, AgentJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
Assert.Equal("SendPayment", deserialized!.ToolName);
Assert.NotNull(deserialized.Arguments);
Assert.Empty(deserialized.Arguments!);
}
/// <summary>
/// Verify that JSON property names are correctly applied.
/// </summary>
[Fact]
public void Serialize_UsesJsonPropertyNames()
{
// Arrange
var rule = new ToolApprovalRule
{
ToolName = "MyTool",
Arguments = new Dictionary<string, string> { ["key"] = "value" },
};
// Act
var json = JsonSerializer.Serialize(rule, AgentJsonUtilities.DefaultOptions);
// Assert
Assert.Contains("\"toolName\"", json);
Assert.Contains("\"arguments\"", json);
}
/// <summary>
/// Verify that a list of rules round-trips through JSON serialization.
/// </summary>
[Fact]
public void Serialize_RuleList_RoundTrips()
{
// Arrange
var rules = new List<ToolApprovalRule>
{
new() { ToolName = "ToolA" },
new() { ToolName = "ToolB", Arguments = new Dictionary<string, string> { ["x"] = "1" } },
};
// Act
var json = JsonSerializer.Serialize(rules, AgentJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<List<ToolApprovalRule>>(json, AgentJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
Assert.Equal(2, deserialized!.Count);
Assert.Equal("ToolA", deserialized[0].ToolName);
Assert.Null(deserialized[0].Arguments);
Assert.Equal("ToolB", deserialized[1].ToolName);
Assert.NotNull(deserialized[1].Arguments);
}
#endregion
}
@@ -0,0 +1,181 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="LoggingAgentBuilderExtensions"/> UseLogging extension method.
/// </summary>
public class LoggingAgentBuilderExtensionsTests
{
/// <summary>
/// Verify that UseLogging throws ArgumentNullException when builder is null.
/// </summary>
[Fact]
public void UseLogging_WithNullBuilder_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("builder", () => ((AIAgentBuilder)null!).UseLogging());
}
/// <summary>
/// Verify that UseLogging returns a LoggingAgent when logger factory is provided.
/// </summary>
[Fact]
public void UseLogging_WithLoggerFactory_ReturnsLoggingAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
using var loggerFactory = LoggerFactory.Create(builder => { });
// Act
AIAgent result = builder.UseLogging(loggerFactory: loggerFactory).Build();
// Assert
Assert.IsType<LoggingAgent>(result);
}
/// <summary>
/// Verify that UseLogging returns the inner agent when NullLoggerFactory is provided.
/// </summary>
[Fact]
public void UseLogging_WithNullLoggerFactory_ReturnsInnerAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
AIAgent result = builder.UseLogging(loggerFactory: NullLoggerFactory.Instance).Build();
// Assert
Assert.NotNull(result);
Assert.IsNotType<LoggingAgent>(result);
}
/// <summary>
/// Verify that UseLogging with configure action works correctly.
/// </summary>
[Fact]
public void UseLogging_WithConfigureAction_CallsConfigureAction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
using var loggerFactory = LoggerFactory.Create(builder => { });
var configureWasCalled = false;
// Act
AIAgent result = builder.UseLogging(
loggerFactory: loggerFactory,
configure: agent =>
{
configureWasCalled = true;
Assert.NotNull(agent);
Assert.IsType<LoggingAgent>(agent);
}).Build();
// Assert
Assert.True(configureWasCalled);
Assert.IsType<LoggingAgent>(result);
}
/// <summary>
/// Verify that UseLogging returns the same builder instance for chaining.
/// </summary>
[Fact]
public void UseLogging_ReturnsBuilderForChaining()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
using var loggerFactory = LoggerFactory.Create(builder => { });
// Act
AIAgentBuilder result = builder.UseLogging(loggerFactory: loggerFactory);
// Assert
Assert.Same(builder, result);
}
/// <summary>
/// Verify that UseLogging with all parameters works correctly.
/// </summary>
[Fact]
public void UseLogging_WithAllParameters_WorksCorrectly()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
using var loggerFactory = LoggerFactory.Create(builder => { });
var builder = new AIAgentBuilder(mockAgent.Object);
var configureWasCalled = false;
// Act
AIAgent result = builder.UseLogging(
loggerFactory: loggerFactory,
configure: agent =>
{
configureWasCalled = true;
Assert.NotNull(agent);
}).Build();
// Assert
Assert.True(configureWasCalled);
Assert.IsType<LoggingAgent>(result);
}
/// <summary>
/// Verify that UseLogging resolves ILoggerFactory from service provider when not provided.
/// </summary>
[Fact]
public void UseLogging_WithoutLoggerFactory_ResolvesFromServiceProvider()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
var services = new ServiceCollection();
using var loggerFactory = LoggerFactory.Create(builder => { });
services.AddSingleton(loggerFactory);
builder.Use((innerAgent, serviceProvider) =>
{
Assert.NotNull(serviceProvider);
return innerAgent;
});
// Act
AIAgent result = builder.UseLogging().Build(services.BuildServiceProvider());
// Assert
Assert.IsType<LoggingAgent>(result);
}
/// <summary>
/// Verify that UseLogging with configure action can customize JsonSerializerOptions.
/// </summary>
[Fact]
public void UseLogging_ConfigureJsonSerializerOptions_WorksCorrectly()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
using var loggerFactory = LoggerFactory.Create(builder => { });
var customOptions = new System.Text.Json.JsonSerializerOptions();
// Act
AIAgent result = builder.UseLogging(
loggerFactory: loggerFactory,
configure: agent => agent.JsonSerializerOptions = customOptions).Build();
// Assert
Assert.IsType<LoggingAgent>(result);
Assert.Same(customOptions, ((LoggingAgent)result).JsonSerializerOptions);
}
}
@@ -0,0 +1,400 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="LoggingAgent"/> class.
/// </summary>
public class LoggingAgentTests
{
[Fact]
public void Ctor_InvalidArgs_Throws()
{
var mockLogger = new Mock<ILogger>();
Assert.Throws<ArgumentNullException>("innerAgent", () => new LoggingAgent(null!, mockLogger.Object));
Assert.Throws<ArgumentNullException>("logger", () => new LoggingAgent(new TestAIAgent(), null!));
}
[Fact]
public void Properties_DelegateToInnerAgent()
{
// Arrange
TestAIAgent innerAgent = new()
{
NameFunc = () => "TestAgent",
DescriptionFunc = () => "This is a test agent.",
};
var mockLogger = new Mock<ILogger>();
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
// Act & Assert
Assert.Equal("TestAgent", agent.Name);
Assert.Equal("This is a test agent.", agent.Description);
Assert.Equal(innerAgent.Id, agent.Id);
}
[Fact]
public void JsonSerializerOptions_Roundtrips()
{
// Arrange
var mockLogger = new Mock<ILogger>();
var agent = new LoggingAgent(new TestAIAgent(), mockLogger.Object);
JsonSerializerOptions options = new();
// Act
agent.JsonSerializerOptions = options;
// Assert
Assert.Same(options, agent.JsonSerializerOptions);
}
[Fact]
public void JsonSerializerOptions_SetNull_Throws()
{
// Arrange
var mockLogger = new Mock<ILogger>();
var agent = new LoggingAgent(new TestAIAgent(), mockLogger.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => agent.JsonSerializerOptions = null!);
}
[Fact]
public async Task RunAsync_LogsAtDebugLevelAsync()
{
// Arrange
var mockLogger = new Mock<ILogger>();
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
mockLogger.Setup(l => l.IsEnabled(LogLevel.Trace)).Returns(false);
var innerAgent = new TestAIAgent
{
RunAsyncFunc = async (messages, session, options, cancellationToken) =>
{
await Task.Yield();
return new AgentResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
}
};
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act
await agent.RunAsync(messages);
// Assert
mockLogger.Verify(
l => l.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunAsync invoked")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
mockLogger.Verify(
l => l.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunAsync completed")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task RunAsync_LogsAtTraceLevel_IncludesSensitiveDataAsync()
{
// Arrange
var mockLogger = new Mock<ILogger>();
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
mockLogger.Setup(l => l.IsEnabled(LogLevel.Trace)).Returns(true);
var innerAgent = new TestAIAgent
{
RunAsyncFunc = async (messages, session, options, cancellationToken) =>
{
await Task.Yield();
return new AgentResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
}
};
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act
await agent.RunAsync(messages);
// Assert
mockLogger.Verify(
l => l.Log(
LogLevel.Trace,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunAsync invoked")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
mockLogger.Verify(
l => l.Log(
LogLevel.Trace,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunAsync completed")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task RunAsync_OnCancellation_LogsCanceledAsync()
{
// Arrange
var mockLogger = new Mock<ILogger>();
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
var innerAgent = new TestAIAgent
{
RunAsyncFunc = (messages, session, options, cancellationToken) =>
throw new OperationCanceledException()
};
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(() => agent.RunAsync(messages));
mockLogger.Verify(
l => l.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("canceled")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task RunAsync_OnException_LogsFailedAsync()
{
// Arrange
var mockLogger = new Mock<ILogger>();
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
mockLogger.Setup(l => l.IsEnabled(LogLevel.Error)).Returns(true);
var innerAgent = new TestAIAgent
{
RunAsyncFunc = (messages, session, options, cancellationToken) =>
throw new InvalidOperationException("Test exception")
};
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync(messages));
mockLogger.Verify(
l => l.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_LogsAtDebugLevelAsync()
{
// Arrange
var mockLogger = new Mock<ILogger>();
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
mockLogger.Setup(l => l.IsEnabled(LogLevel.Trace)).Returns(false);
var innerAgent = new TestAIAgent
{
RunStreamingAsyncFunc = CallbackAsync
};
static async IAsyncEnumerable<AgentResponseUpdate> CallbackAsync(
IEnumerable<ChatMessage> messages, AgentSession? session, AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
yield return new AgentResponseUpdate(ChatRole.Assistant, "Test");
}
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act
await foreach (var update in agent.RunStreamingAsync(messages))
{
// Consume the stream
}
// Assert
mockLogger.Verify(
l => l.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunStreamingAsync invoked")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
mockLogger.Verify(
l => l.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunStreamingAsync completed")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_LogsUpdatesAtTraceLevelAsync()
{
// Arrange
var mockLogger = new Mock<ILogger>();
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
mockLogger.Setup(l => l.IsEnabled(LogLevel.Trace)).Returns(true);
var innerAgent = new TestAIAgent
{
RunStreamingAsyncFunc = CallbackAsync
};
static async IAsyncEnumerable<AgentResponseUpdate> CallbackAsync(
IEnumerable<ChatMessage> messages, AgentSession? session, AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
yield return new AgentResponseUpdate(ChatRole.Assistant, "Update 1");
yield return new AgentResponseUpdate(ChatRole.Assistant, "Update 2");
}
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act
await foreach (var update in agent.RunStreamingAsync(messages))
{
// Consume the stream
}
// Assert
mockLogger.Verify(
l => l.Log(
LogLevel.Trace,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("received update")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Exactly(2));
}
[Fact]
public async Task RunStreamingAsync_OnCancellation_LogsCanceledAsync()
{
// Arrange
var mockLogger = new Mock<ILogger>();
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
var innerAgent = new TestAIAgent
{
RunStreamingAsyncFunc = CallbackAsync
};
static async IAsyncEnumerable<AgentResponseUpdate> CallbackAsync(
IEnumerable<ChatMessage> messages, AgentSession? session, AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
throw new OperationCanceledException();
// The following yield statement is required for async iterator methods but is unreachable.
// This pattern is intentional for testing exception scenarios in async iterators.
#pragma warning disable CS0162 // Unreachable code detected
yield break;
#pragma warning restore CS0162 // Unreachable code detected
}
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{
await foreach (var update in agent.RunStreamingAsync(messages))
{
// Consume the stream
}
});
mockLogger.Verify(
l => l.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("canceled")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_OnException_LogsFailedAsync()
{
// Arrange
var mockLogger = new Mock<ILogger>();
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
mockLogger.Setup(l => l.IsEnabled(LogLevel.Error)).Returns(true);
var innerAgent = new TestAIAgent
{
RunStreamingAsyncFunc = CallbackAsync
};
static async IAsyncEnumerable<AgentResponseUpdate> CallbackAsync(
IEnumerable<ChatMessage> messages, AgentSession? session, AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
throw new InvalidOperationException("Test exception");
// The following yield statement is required for async iterator methods but is unreachable.
// This pattern is intentional for testing exception scenarios in async iterators.
#pragma warning disable CS0162 // Unreachable code detected
yield break;
#pragma warning restore CS0162 // Unreachable code detected
}
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var update in agent.RunStreamingAsync(messages))
{
// Consume the stream
}
});
mockLogger.Verify(
l => l.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
}
@@ -0,0 +1,959 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.VectorData;
using Moq;
namespace Microsoft.Agents.AI.Memory.UnitTests;
/// <summary>
/// Contains unit tests for the <see cref="ChatHistoryMemoryProvider"/> class.
/// </summary>
public class ChatHistoryMemoryProviderTests
{
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
private readonly Mock<ILogger<ChatHistoryMemoryProvider>> _loggerMock;
private readonly Mock<ILoggerFactory> _loggerFactoryMock;
private readonly Mock<VectorStore> _vectorStoreMock;
private readonly Mock<VectorStoreCollection<object, Dictionary<string, object?>>> _vectorStoreCollectionMock;
private const string TestCollectionName = "testcollection";
public ChatHistoryMemoryProviderTests()
{
this._loggerMock = new();
this._loggerFactoryMock = new();
this._loggerFactoryMock
.Setup(f => f.CreateLogger(It.IsAny<string>()))
.Returns(this._loggerMock.Object);
this._loggerFactoryMock
.Setup(f => f.CreateLogger(typeof(ChatHistoryMemoryProvider).FullName!))
.Returns(this._loggerMock.Object);
this._loggerMock
.Setup(f => f.IsEnabled(It.IsAny<LogLevel>()))
.Returns(true);
this._vectorStoreCollectionMock = new(MockBehavior.Strict);
this._vectorStoreMock = new(MockBehavior.Strict);
this._vectorStoreCollectionMock
.Setup(c => c.EnsureCollectionExistsAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
this._vectorStoreMock
.Setup(vs => vs.GetDynamicCollection(
It.IsAny<string>(),
It.IsAny<VectorStoreCollectionDefinition>()))
.Returns(this._vectorStoreCollectionMock.Object);
}
[Fact]
public void StateKeys_ReturnsDefaultKey_WhenNoOptionsProvided()
{
// Arrange & Act
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }));
// Assert
Assert.Single(provider.StateKeys);
Assert.Contains("ChatHistoryMemoryProvider", provider.StateKeys);
}
[Fact]
public void StateKeys_ReturnsCustomKey_WhenSetViaOptions()
{
// Arrange & Act
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }),
new ChatHistoryMemoryProviderOptions { StateKey = "custom-key" });
// Assert
Assert.Single(provider.StateKeys);
Assert.Contains("custom-key", provider.StateKeys);
}
[Fact]
public void Constructor_Throws_ForNullVectorStore()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new ChatHistoryMemoryProvider(
null!,
"testcollection",
1,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" })));
}
[Fact]
public void Constructor_Throws_ForNullCollectionName()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
null!,
1,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" })));
}
[Fact]
public void Constructor_Throws_ForNullStateInitializer()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
"testcollection",
1,
null!));
}
[Fact]
public void Constructor_Throws_ForInvalidVectorDimensions()
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() => new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
"testcollection",
0,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" })));
Assert.Throws<ArgumentOutOfRangeException>(() => new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
"testcollection",
-5,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" })));
}
#region InvokedAsync Tests
[Fact]
public async Task InvokedAsync_UpsertsMessages_ToCollectionAsync()
{
// Arrange
var stored = new List<Dictionary<string, object?>>();
this._vectorStoreCollectionMock
.Setup(c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<Dictionary<string, object?>>, CancellationToken>((items, ct) =>
{
if (items != null)
{
stored.AddRange(items);
}
})
.Returns(Task.CompletedTask);
var storeScope = new ChatHistoryMemoryProviderScope
{
ApplicationId = "app1",
AgentId = "agent1",
SessionId = "session1",
UserId = "user1"
};
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(storeScope));
var requestMsgWithValues = new ChatMessage(ChatRole.User, "request text") { MessageId = "req-1", AuthorName = "user1", CreatedAt = new DateTimeOffset(new DateTime(2000, 1, 1), TimeSpan.Zero) };
var requestMsgWithNulls = new ChatMessage(ChatRole.User, "request text nulls");
var responseMsg = new ChatMessage(ChatRole.Assistant, "response text") { MessageId = "resp-1", AuthorName = "assistant" };
var invokedContext = new AIContextProvider.InvokedContext(s_mockAgent, new TestAgentSession(), [requestMsgWithValues, requestMsgWithNulls], [responseMsg]);
// Act
await provider.InvokedAsync(invokedContext, CancellationToken.None);
// Assert
this._vectorStoreCollectionMock.Verify(
m => m.EnsureCollectionExistsAsync(It.IsAny<CancellationToken>()),
Times.Once);
Assert.Equal(3, stored.Count);
Assert.Equal("req-1", stored[0]["MessageId"]);
Assert.Equal("request text", stored[0]["Content"]);
Assert.Equal("user1", stored[0]["AuthorName"]);
Assert.Equal(ChatRole.User.ToString(), stored[0]["Role"]);
Assert.Equal("2000-01-01T00:00:00.0000000+00:00", stored[0]["CreatedAt"]);
Assert.Equal("app1", stored[0]["ApplicationId"]);
Assert.Equal("agent1", stored[0]["AgentId"]);
Assert.Equal("session1", stored[0]["SessionId"]);
Assert.Equal("user1", stored[0]["UserId"]);
Assert.Null(stored[1]["MessageId"]);
Assert.Equal("request text nulls", stored[1]["Content"]);
Assert.Null(stored[1]["AuthorName"]);
Assert.Equal(ChatRole.User.ToString(), stored[1]["Role"]);
Assert.Equal("app1", stored[1]["ApplicationId"]);
Assert.Equal("agent1", stored[1]["AgentId"]);
Assert.Equal("session1", stored[1]["SessionId"]);
Assert.Equal("user1", stored[1]["UserId"]);
Assert.Equal("resp-1", stored[2]["MessageId"]);
Assert.Equal("response text", stored[2]["Content"]);
Assert.Equal("assistant", stored[2]["AuthorName"]);
Assert.Equal(ChatRole.Assistant.ToString(), stored[2]["Role"]);
Assert.Equal("app1", stored[2]["ApplicationId"]);
Assert.Equal("agent1", stored[2]["AgentId"]);
Assert.Equal("session1", stored[2]["SessionId"]);
Assert.Equal("user1", stored[2]["UserId"]);
}
[Fact]
public async Task InvokedAsync_DoesNotUpsertMessages_WhenInvokeFailedAsync()
{
// Arrange
this._vectorStoreCollectionMock
.Setup(c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }));
var requestMsg = new ChatMessage(ChatRole.User, "request text") { MessageId = "req-1" };
var invokedContext = new AIContextProvider.InvokedContext(s_mockAgent, new TestAgentSession(), [requestMsg], new InvalidOperationException("Invoke failed"));
// Act
await provider.InvokedAsync(invokedContext, CancellationToken.None);
// Assert
this._vectorStoreCollectionMock.Verify(
c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task InvokedAsync_DoesNotThrow_WhenUpsertThrowsAsync()
{
// Arrange
this._vectorStoreCollectionMock
.Setup(c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Upsert failed"));
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }),
loggerFactory: this._loggerFactoryMock.Object);
var requestMsg = new ChatMessage(ChatRole.User, "request text") { MessageId = "req-1" };
var invokedContext = new AIContextProvider.InvokedContext(s_mockAgent, new TestAgentSession(), [requestMsg], []);
// Act
await provider.InvokedAsync(invokedContext, CancellationToken.None);
// Assert
this._loggerMock.Verify(
l => l.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("ChatHistoryMemoryProvider: Failed to add messages to chat history vector store due to error")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Theory]
[InlineData(false, false, false, 0)]
[InlineData(false, false, true, 0)]
[InlineData(true, false, false, 0)]
[InlineData(true, false, true, 0)]
[InlineData(false, true, false, 2)]
[InlineData(false, true, true, 2)]
[InlineData(true, true, false, 2)]
[InlineData(true, true, true, 2)]
public async Task InvokedAsync_RedactsLogDataBasedOnOptionsAsync(bool enableSensitiveTelemetryData, bool requestThrows, bool useCustomRedactor, int expectedLogInvocations)
{
// Arrange
var options = new ChatHistoryMemoryProviderOptions
{
EnableSensitiveTelemetryData = enableSensitiveTelemetryData,
Redactor = useCustomRedactor ? new ReplacingRedactor("***") : null
};
if (requestThrows)
{
this._vectorStoreCollectionMock
.Setup(c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Upsert failed"));
}
else
{
this._vectorStoreCollectionMock
.Setup(c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
}
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "user1" }),
options: options,
loggerFactory: this._loggerFactoryMock.Object);
var requestMsg = new ChatMessage(ChatRole.User, "request text");
var invokedContext = new AIContextProvider.InvokedContext(s_mockAgent, new TestAgentSession(), [requestMsg], []);
// Act
await provider.InvokedAsync(invokedContext, CancellationToken.None);
// Assert — EnableSensitiveTelemetryData takes precedence over Redactor
Assert.Equal(expectedLogInvocations, this._loggerMock.Invocations.Count);
foreach (var logInvocation in this._loggerMock.Invocations)
{
if (logInvocation.Method.Name == nameof(ILogger.IsEnabled))
{
continue;
}
var state = Assert.IsType<IReadOnlyList<KeyValuePair<string, object?>>>(logInvocation.Arguments[2], exactMatch: false);
var userIdValue = state.First(kvp => kvp.Key == "UserId").Value;
string expectedRedaction = enableSensitiveTelemetryData ? "user1" : (useCustomRedactor ? "***" : "<redacted>");
Assert.Equal(expectedRedaction, userIdValue);
}
}
#endregion
#region InvokingAsync Tests
[Fact]
public async Task InvokedAsync_SearchesVectorStoreAsync()
{
// Arrange
var providerOptions = new ChatHistoryMemoryProviderOptions
{
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke,
MaxResults = 2,
ContextPrompt = "Here is the relevant chat history:\n"
};
var storedItems = new List<VectorSearchResult<Dictionary<string, object?>>>
{
new(
new Dictionary<string, object?>
{
["MessageId"] = "msg-1",
["Content"] = "First stored message",
["Role"] = ChatRole.User.ToString(),
["CreatedAt"] = "2023-01-01T00:00:00.0000000+00:00"
},
0.9f),
new(
new Dictionary<string, object?>
{
["MessageId"] = "msg-2",
["Content"] = "Second stored message",
["Role"] = ChatRole.User.ToString(),
["CreatedAt"] = "2023-01-02T00:00:00.0000000+00:00"
},
0.8f)
};
this._vectorStoreCollectionMock
.Setup(c => c.SearchAsync(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(storedItems));
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }),
options: providerOptions);
var requestMsg = new ChatMessage(ChatRole.User, "requesting relevant history");
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List<ChatMessage> { requestMsg } });
// Act
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
this._vectorStoreCollectionMock.Verify(
c => c.SearchAsync(
It.Is<string>(s => s == "requesting relevant history"),
2,
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()),
Times.Once);
Assert.NotNull(aiContext.Messages);
var messages = aiContext.Messages.ToList();
Assert.Equal(2, messages.Count);
Assert.Equal(AgentRequestMessageSourceType.External, messages[0].GetAgentRequestMessageSourceType());
Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, messages[1].GetAgentRequestMessageSourceType());
}
[Fact]
public async Task InvokedAsync_CreatesFilter_WhenSearchScopeProvidedAsync()
{
// Arrange
var providerOptions = new ChatHistoryMemoryProviderOptions
{
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke,
MaxResults = 2,
ContextPrompt = "Here is the relevant chat history:\n"
};
var searchScope = new ChatHistoryMemoryProviderScope
{
ApplicationId = "app1",
AgentId = "agent1",
SessionId = "session1",
UserId = "user1"
};
this._vectorStoreCollectionMock
.Setup(c => c.SearchAsync(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()))
.Callback((string query, int maxResults, VectorSearchOptions<Dictionary<string, object?>> options, CancellationToken ct) =>
{
// Verify that the filter was created correctly
const string ExpectedFilter = "x => ((((x.ApplicationId == value(Microsoft.Agents.AI.VectorDataMemory.ChatHistoryMemoryProvider+<>c__DisplayClass20_0).applicationId) AndAlso (x.AgentId == value(Microsoft.Agents.AI.VectorDataMemory.ChatHistoryMemoryProvider+<>c__DisplayClass20_0).agentId)) AndAlso (x.UserId == value(Microsoft.Agents.AI.VectorDataMemory.ChatHistoryMemoryProvider+<>c__DisplayClass20_0).userId)) AndAlso (x.SessionId == value(Microsoft.Agents.AI.VectorDataMemory.ChatHistoryMemoryProvider+<>c__DisplayClass20_0).sessionId))";
Assert.Equal(ExpectedFilter, options.Filter!.ToString());
})
.Returns(ToAsyncEnumerableAsync(new List<VectorSearchResult<Dictionary<string, object?>>>()));
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(searchScope, searchScope),
options: providerOptions);
var requestMsg = new ChatMessage(ChatRole.User, "requesting relevant history");
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List<ChatMessage> { requestMsg } });
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
this._vectorStoreCollectionMock.Verify(
c => c.SearchAsync(
It.Is<string>(s => s == "requesting relevant history"),
2,
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task InvokedAsync_CombinedFilterCanBeCompiled_WhenMultipleScopeFiltersProvidedAsync()
{
// Arrange
// This test reproduces a bug where combining multiple scope filters
// (e.g. userId + sessionId) produces an expression tree with dangling
// ParameterExpression references that fails at compile time.
ChatHistoryMemoryProviderOptions providerOptions = new()
{
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke,
MaxResults = 2,
ContextPrompt = "Here is the relevant chat history:\n"
};
ChatHistoryMemoryProviderScope searchScope = new()
{
ApplicationId = "app1",
AgentId = "agent1",
SessionId = "session1",
UserId = "user1"
};
System.Linq.Expressions.Expression<Func<Dictionary<string, object?>, bool>>? capturedFilter = null;
this._vectorStoreCollectionMock
.Setup(c => c.SearchAsync(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()))
.Callback((string query, int maxResults, VectorSearchOptions<Dictionary<string, object?>> options, CancellationToken ct) =>
capturedFilter = options.Filter)
.Returns(ToAsyncEnumerableAsync(new List<VectorSearchResult<Dictionary<string, object?>>>()));
ChatHistoryMemoryProvider provider = new(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(searchScope, searchScope),
options: providerOptions);
ChatMessage requestMsg = new(ChatRole.User, "requesting relevant history");
AIContextProvider.InvokingContext invokingContext = new(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List<ChatMessage> { requestMsg } });
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert - The filter must be compilable and executable without expression tree scoping errors
Assert.NotNull(capturedFilter);
Func<Dictionary<string, object?>, bool> compiledFilter = capturedFilter!.Compile();
Dictionary<string, object?> matchingRecord = new()
{
["ApplicationId"] = "app1",
["AgentId"] = "agent1",
["SessionId"] = "session1",
["UserId"] = "user1"
};
Dictionary<string, object?> nonMatchingRecord = new()
{
["ApplicationId"] = "app1",
["AgentId"] = "agent1",
["SessionId"] = "other-session",
["UserId"] = "user1"
};
Assert.True(compiledFilter(matchingRecord));
Assert.False(compiledFilter(nonMatchingRecord));
}
[Theory]
[InlineData(false, false, false, 2)]
[InlineData(false, false, true, 2)]
[InlineData(true, false, false, 2)]
[InlineData(true, false, true, 2)]
[InlineData(false, true, false, 2)]
[InlineData(false, true, true, 2)]
[InlineData(true, true, false, 2)]
[InlineData(true, true, true, 2)]
public async Task InvokingAsync_RedactsLogDataBasedOnOptionsAsync(bool enableSensitiveTelemetryData, bool requestThrows, bool useCustomRedactor, int expectedLogInvocations)
{
// Arrange
var options = new ChatHistoryMemoryProviderOptions
{
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke,
EnableSensitiveTelemetryData = enableSensitiveTelemetryData,
Redactor = useCustomRedactor ? new ReplacingRedactor("***") : null
};
var scope = new ChatHistoryMemoryProviderScope
{
UserId = "user1"
};
if (requestThrows)
{
this._vectorStoreCollectionMock
.Setup(c => c.SearchAsync(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()))
.Throws(new InvalidOperationException("Search failed"));
}
else
{
this._vectorStoreCollectionMock
.Setup(c => c.SearchAsync(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(new List<VectorSearchResult<Dictionary<string, object?>>>()));
}
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(scope, scope),
options: options,
loggerFactory: this._loggerFactoryMock.Object);
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List<ChatMessage> { new(ChatRole.User, "requesting relevant history") } });
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert — EnableSensitiveTelemetryData takes precedence over Redactor
string expectedRedaction = enableSensitiveTelemetryData ? "user1" : (useCustomRedactor ? "***" : "<redacted>");
Assert.Equal(expectedLogInvocations, this._loggerMock.Invocations.Count);
foreach (var logInvocation in this._loggerMock.Invocations)
{
if (logInvocation.Method.Name == nameof(ILogger.IsEnabled))
{
continue;
}
var state = Assert.IsType<IReadOnlyList<KeyValuePair<string, object?>>>(logInvocation.Arguments[2], exactMatch: false);
var userIdValue = state.First(kvp => kvp.Key == "UserId").Value;
Assert.Equal(expectedRedaction, userIdValue);
var inputValue = state.FirstOrDefault(kvp => kvp.Key == "Input").Value;
if (inputValue != null)
{
Assert.Equal(enableSensitiveTelemetryData ? "Who am I?" : expectedRedaction, inputValue);
}
var messageTextValue = state.FirstOrDefault(kvp => kvp.Key == "MessageText").Value;
if (messageTextValue != null)
{
Assert.Equal(enableSensitiveTelemetryData ? "## Memories\nConsider the following memories when answering user questions:\nName is Caoimhe" : expectedRedaction, messageTextValue);
}
}
}
#endregion
#region Message Filter Tests
[Fact]
public async Task InvokingAsync_DefaultFilter_ExcludesNonExternalMessagesFromSearchAsync()
{
// Arrange
var providerOptions = new ChatHistoryMemoryProviderOptions
{
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke,
};
string? capturedQuery = null;
this._vectorStoreCollectionMock
.Setup(c => c.SearchAsync(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()))
.Callback<string, int, VectorSearchOptions<Dictionary<string, object?>>, CancellationToken>((query, _, _, _) => capturedQuery = query)
.Returns(ToAsyncEnumerableAsync(new List<VectorSearchResult<Dictionary<string, object?>>>()));
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }),
options: providerOptions);
var requestMessages = new List<ChatMessage>
{
new(ChatRole.User, "External message"),
new(ChatRole.System, "From history") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "HistorySource") } } },
new(ChatRole.System, "From context provider") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "ContextSource") } } },
};
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = requestMessages });
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert - Only External message used for search query
Assert.Equal("External message", capturedQuery);
}
[Fact]
public async Task InvokingAsync_CustomSearchInputFilter_OverridesDefaultAsync()
{
// Arrange
var providerOptions = new ChatHistoryMemoryProviderOptions
{
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke,
SearchInputMessageFilter = messages => messages // No filtering
};
string? capturedQuery = null;
this._vectorStoreCollectionMock
.Setup(c => c.SearchAsync(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()))
.Callback<string, int, VectorSearchOptions<Dictionary<string, object?>>, CancellationToken>((query, _, _, _) => capturedQuery = query)
.Returns(ToAsyncEnumerableAsync(new List<VectorSearchResult<Dictionary<string, object?>>>()));
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }),
options: providerOptions);
var requestMessages = new List<ChatMessage>
{
new(ChatRole.User, "External message"),
new(ChatRole.System, "From history") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "HistorySource") } } },
};
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = requestMessages });
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert - Both messages should be included in search query (identity filter)
Assert.NotNull(capturedQuery);
Assert.Contains("External message", capturedQuery);
Assert.Contains("From history", capturedQuery);
}
[Fact]
public async Task InvokedAsync_DefaultFilter_ExcludesNonExternalMessagesFromStorageAsync()
{
// Arrange
var stored = new List<Dictionary<string, object?>>();
this._vectorStoreCollectionMock
.Setup(c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<Dictionary<string, object?>>, CancellationToken>((items, ct) =>
{
if (items != null)
{
stored.AddRange(items);
}
})
.Returns(Task.CompletedTask);
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }));
var requestMessages = new List<ChatMessage>
{
new(ChatRole.User, "External message"),
new(ChatRole.System, "From history") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "HistorySource") } } },
new(ChatRole.System, "From context provider") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "ContextSource") } } },
};
var invokedContext = new AIContextProvider.InvokedContext(s_mockAgent, new TestAgentSession(), requestMessages, [new ChatMessage(ChatRole.Assistant, "Response")]);
// Act
await provider.InvokedAsync(invokedContext, CancellationToken.None);
// Assert - Only External message + response stored (ChatHistory and AIContextProvider excluded by default)
Assert.Equal(2, stored.Count);
Assert.Equal("External message", stored[0]["Content"]);
Assert.Equal("Response", stored[1]["Content"]);
}
[Fact]
public async Task InvokedAsync_CustomStorageInputFilter_OverridesDefaultAsync()
{
// Arrange
var stored = new List<Dictionary<string, object?>>();
this._vectorStoreCollectionMock
.Setup(c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<Dictionary<string, object?>>, CancellationToken>((items, ct) =>
{
if (items != null)
{
stored.AddRange(items);
}
})
.Returns(Task.CompletedTask);
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }),
options: new ChatHistoryMemoryProviderOptions
{
StorageInputRequestMessageFilter = messages => messages // No filtering - store everything
});
var requestMessages = new List<ChatMessage>
{
new(ChatRole.User, "External message"),
new(ChatRole.System, "From history") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "HistorySource") } } },
};
var invokedContext = new AIContextProvider.InvokedContext(s_mockAgent, new TestAgentSession(), requestMessages, [new ChatMessage(ChatRole.Assistant, "Response")]);
// Act
await provider.InvokedAsync(invokedContext, CancellationToken.None);
// Assert - All messages stored (identity filter overrides default)
Assert.Equal(3, stored.Count);
Assert.Equal("External message", stored[0]["Content"]);
Assert.Equal("From history", stored[1]["Content"]);
Assert.Equal("Response", stored[2]["Content"]);
}
#endregion
#region MessageAIContextProvider.InvokingAsync Tests
[Fact]
public async Task MessageInvokingAsync_BeforeAIInvoke_SearchesAndReturnsMergedMessagesAsync()
{
// Arrange
var storedItems = new List<VectorSearchResult<Dictionary<string, object?>>>
{
new(
new Dictionary<string, object?>
{
["MessageId"] = "msg-1",
["Content"] = "Previous message",
["Role"] = ChatRole.User.ToString(),
["CreatedAt"] = "2023-01-01T00:00:00.0000000+00:00"
},
0.9f)
};
this._vectorStoreCollectionMock
.Setup(c => c.SearchAsync(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(storedItems));
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }),
options: new ChatHistoryMemoryProviderOptions
{
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke
});
var inputMsg = new ChatMessage(ChatRole.User, "What was discussed?");
var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [inputMsg]);
// Act
var messages = (await provider.InvokingAsync(context)).ToList();
// Assert - input message + search result message, with stamping
Assert.Equal(2, messages.Count);
Assert.Equal("What was discussed?", messages[0].Text);
Assert.Contains("Previous message", messages[1].Text);
Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, messages[1].GetAgentRequestMessageSourceType());
}
[Fact]
public async Task MessageInvokingAsync_OnDemand_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }),
options: new ChatHistoryMemoryProviderOptions
{
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.OnDemandFunctionCalling
});
var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [new ChatMessage(ChatRole.User, "Q?")]);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => provider.InvokingAsync(context).AsTask());
}
[Fact]
public async Task MessageInvokingAsync_BeforeAIInvoke_NoResults_ReturnsOnlyInputMessagesAsync()
{
// Arrange
this._vectorStoreCollectionMock
.Setup(c => c.SearchAsync(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(new List<VectorSearchResult<Dictionary<string, object?>>>()));
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }),
options: new ChatHistoryMemoryProviderOptions
{
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke
});
var inputMsg = new ChatMessage(ChatRole.User, "Hello");
var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [inputMsg]);
// Act
var messages = (await provider.InvokingAsync(context)).ToList();
// Assert
Assert.Single(messages);
Assert.Equal("Hello", messages[0].Text);
}
[Fact]
public async Task MessageInvokingAsync_BeforeAIInvoke_DefaultFilter_ExcludesNonExternalMessagesAsync()
{
// Arrange
string? capturedQuery = null;
this._vectorStoreCollectionMock
.Setup(c => c.SearchAsync(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()))
.Callback<string, int, VectorSearchOptions<Dictionary<string, object?>>, CancellationToken>((query, _, _, _) => capturedQuery = query)
.Returns(ToAsyncEnumerableAsync(new List<VectorSearchResult<Dictionary<string, object?>>>()));
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
_ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "UID" }),
options: new ChatHistoryMemoryProviderOptions
{
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke
});
var externalMsg = new ChatMessage(ChatRole.User, "External message");
var historyMsg = new ChatMessage(ChatRole.System, "From history")
.WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, "src");
var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [externalMsg, historyMsg]);
// Act
await provider.InvokingAsync(context);
// Assert - Only External message used for search query
Assert.Equal("External message", capturedQuery);
}
#endregion
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> values)
{
await Task.Yield();
foreach (var update in values)
{
yield return update;
}
}
private sealed class TestAgentSession : AgentSession
{
public TestAgentSession()
{
}
public TestAgentSession(AgentSessionStateBag stateBag)
{
this.StateBag = stateBag;
}
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<NoWarn>$(NoWarn);MAAI001</NoWarn>
</PropertyGroup>
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.CopilotStudio\Microsoft.Agents.AI.CopilotStudio.csproj" />
</ItemGroup>
<!-- Evaluation tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="EvaluationTests.cs" />
<Compile Remove="AgentSkills\HostedAgentSkillsPatternTests.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.ML.Tokenizers" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
<PackageReference Include="System.Linq.AsyncEnumerable" />
</ItemGroup>
</Project>
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.UnitTests;
internal sealed class Animal
{
public int Id { get; set; }
public string? FullName { get; set; }
public Species Species { get; set; }
}
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.UnitTests;
internal enum Species
{
Bear,
Tiger,
Walrus,
}
@@ -0,0 +1,126 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.Logging;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="OpenTelemetryAgentBuilderExtensions"/> class.
/// </summary>
public class OpenTelemetryAgentBuilderExtensionsTests
{
/// <summary>
/// Verify that UseOpenTelemetry throws ArgumentNullException when builder is null.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithNullBuilder_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("builder", () => ((AIAgentBuilder)null!).UseOpenTelemetry());
}
/// <summary>
/// Verify that UseOpenTelemetry returns an OpenTelemetryAgent.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithValidBuilder_ReturnsOpenTelemetryAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.UseOpenTelemetry().Build();
// Assert
Assert.IsType<OpenTelemetryAgent>(result);
}
/// <summary>
/// Verify that UseOpenTelemetry with source name works correctly.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithSourceName_WorksCorrectly()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
const string SourceName = "TestSource";
// Act
var result = builder.UseOpenTelemetry(sourceName: SourceName).Build();
// Assert
Assert.IsType<OpenTelemetryAgent>(result);
}
/// <summary>
/// Verify that UseOpenTelemetry with configure action works correctly.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithConfigureAction_CallsConfigureAction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
var configureWasCalled = false;
// Act
var result = builder.UseOpenTelemetry(configure: agent =>
{
configureWasCalled = true;
Assert.NotNull(agent);
Assert.IsType<OpenTelemetryAgent>(agent);
}).Build();
// Assert
Assert.True(configureWasCalled);
Assert.IsType<OpenTelemetryAgent>(result);
}
/// <summary>
/// Verify that UseOpenTelemetry returns the same builder instance for chaining.
/// </summary>
[Fact]
public void UseOpenTelemetry_ReturnsBuilderForChaining()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.UseOpenTelemetry();
// Assert
Assert.Same(builder, result);
}
/// <summary>
/// Verify that UseOpenTelemetry with all parameters works correctly.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithAllParameters_WorksCorrectly()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
using var loggerFactory = LoggerFactory.Create(builder => { });
var builder = new AIAgentBuilder(mockAgent.Object);
const string SourceName = "TestSource";
var configureWasCalled = false;
// Act
var result = builder.UseOpenTelemetry(
sourceName: SourceName,
configure: agent =>
{
configureWasCalled = true;
Assert.NotNull(agent);
}).Build();
// Assert
Assert.True(configureWasCalled);
Assert.IsType<OpenTelemetryAgent>(result);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
internal sealed class TestAIAgent : AIAgent
{
public Func<string>? NameFunc;
public Func<string>? DescriptionFunc;
public readonly Func<JsonElement, JsonSerializerOptions?, AgentSession> DeserializeSessionFunc = delegate { throw new NotSupportedException(); };
public readonly Func<AgentSession> CreateSessionFunc = delegate { throw new NotSupportedException(); };
public Func<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken, Task<AgentResponse>> RunAsyncFunc = delegate { throw new NotSupportedException(); };
public Func<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken, IAsyncEnumerable<AgentResponseUpdate>> RunStreamingAsyncFunc = delegate { throw new NotSupportedException(); };
public Func<Type, object?, object?>? GetServiceFunc;
public override string? Name => this.NameFunc?.Invoke() ?? base.Name;
public override string? Description => this.DescriptionFunc?.Invoke() ?? base.Description;
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
new(this.DeserializeSessionFunc(serializedState, jsonSerializerOptions));
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
new(this.CreateSessionFunc());
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
this.RunAsyncFunc(messages, session, options, cancellationToken);
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
this.RunStreamingAsyncFunc(messages, session, options, cancellationToken);
public override object? GetService(Type serviceType, object? serviceKey = null) =>
this.GetServiceFunc is { } func ? func(serviceType, serviceKey) :
base.GetService(serviceType, serviceKey);
}
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI;
internal static class TestAgentSkillsSourceContextFactory
{
public static AgentSkillsSourceContext Create(AIAgent? agent = null)
=> new(agent ?? new TestAIAgent(), session: null);
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.UnitTests;
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UseStringEnumConverter = true)]
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
[JsonSerializable(typeof(ChatClientAgentSessionTests.Animal))]
[JsonSerializable(typeof(ChatClientAgentSession))]
internal sealed partial class TestJsonSerializerContext : JsonSerializerContext;