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:
+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
|
||||
}
|
||||
Reference in New Issue
Block a user