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
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+591
@@ -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
|
||||
}
|
||||
+469
@@ -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;
|
||||
}
|
||||
}
|
||||
+289
@@ -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);
|
||||
}
|
||||
}
|
||||
+51
@@ -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!));
|
||||
}
|
||||
}
|
||||
+348
@@ -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><available_resources></c> and <c><available_scripts></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<b>&"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 <special> & "chars"\"", 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<b>&"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 <special> & "chars"\"", 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<b>&"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);
|
||||
}
|
||||
}
|
||||
+225
@@ -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";
|
||||
}
|
||||
+289
@@ -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<y>z"w & it's more</description>", content);
|
||||
Assert.Contains("1 & 2 < 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()!);
|
||||
}
|
||||
}
|
||||
+260
@@ -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));
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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,
|
||||
};
|
||||
}
|
||||
+261
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+562
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+157
@@ -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);
|
||||
}
|
||||
}
|
||||
+321
@@ -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);
|
||||
}
|
||||
+344
@@ -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<JsonElement?, AIFunctionArguments></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
+666
@@ -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
|
||||
}
|
||||
+128
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
+422
@@ -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
+306
@@ -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
|
||||
}
|
||||
+833
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+654
@@ -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
|
||||
}
|
||||
+552
@@ -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);
|
||||
}
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
+456
@@ -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;
|
||||
}
|
||||
+212
@@ -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;
|
||||
}
|
||||
+56
@@ -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;
|
||||
}
|
||||
+215
@@ -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" } }));
|
||||
}
|
||||
}
|
||||
+493
@@ -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");
|
||||
}
|
||||
}
|
||||
+1374
File diff suppressed because it is too large
Load Diff
+518
@@ -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
|
||||
}
|
||||
+255
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1508
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
}
|
||||
+219
@@ -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);
|
||||
}
|
||||
}
|
||||
+208
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+311
@@ -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
|
||||
}
|
||||
}
|
||||
+613
@@ -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
|
||||
}
|
||||
+438
@@ -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);
|
||||
}
|
||||
}
|
||||
+328
@@ -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
+1046
File diff suppressed because it is too large
Load Diff
+665
@@ -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
|
||||
}
|
||||
+968
@@ -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
|
||||
}
|
||||
+1084
File diff suppressed because it is too large
Load Diff
+1207
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
+1007
File diff suppressed because it is too large
Load Diff
+658
@@ -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")]));
|
||||
}
|
||||
+319
@@ -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);
|
||||
}
|
||||
}
|
||||
+145
@@ -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)]));
|
||||
}
|
||||
+113
@@ -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; } = [];
|
||||
}
|
||||
+259
@@ -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
|
||||
}
|
||||
+288
@@ -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
|
||||
}
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
+2052
File diff suppressed because it is too large
Load Diff
+180
@@ -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,
|
||||
}
|
||||
+126
@@ -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;
|
||||
Reference in New Issue
Block a user