mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into copilot/process-workflow-output-event
This commit is contained in:
+30
@@ -3,7 +3,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -114,6 +116,29 @@ public class InMemoryChatMessageStoreTests
|
||||
Assert.Equal("B", newStore[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SerializeAndDeserializeConstructorRoundtripsWithCustomAIContentAsync()
|
||||
{
|
||||
JsonSerializerOptions options = new(TestJsonSerializerContext.Default.Options)
|
||||
{
|
||||
TypeInfoResolver = JsonTypeInfoResolver.Combine(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver, TestJsonSerializerContext.Default),
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
options.AddAIContentType<TestAIContent>(typeDiscriminatorId: "testContent");
|
||||
|
||||
var store = new InMemoryChatMessageStore
|
||||
{
|
||||
new ChatMessage(ChatRole.User, [new TestAIContent("foo data")]),
|
||||
};
|
||||
|
||||
var jsonElement = store.Serialize(options);
|
||||
var newStore = new InMemoryChatMessageStore(jsonElement, options);
|
||||
|
||||
Assert.Single(newStore);
|
||||
var actualTestAIContent = Assert.IsType<TestAIContent>(newStore[0].Contents[0]);
|
||||
Assert.Equal("foo data", actualTestAIContent.TestData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SerializeAndDeserializeWorksWithExperimentalContentTypesAsync()
|
||||
{
|
||||
@@ -558,4 +583,9 @@ public class InMemoryChatMessageStoreTests
|
||||
Assert.Equal("Hello", result[0].Text);
|
||||
reducerMock.Verify(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
public class TestAIContent(string testData) : AIContent
|
||||
{
|
||||
public string TestData => testData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,5 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
[JsonSerializable(typeof(InMemoryAgentThread.InMemoryAgentThreadState))]
|
||||
[JsonSerializable(typeof(ServiceIdAgentThread.ServiceIdAgentThreadState))]
|
||||
[JsonSerializable(typeof(ServiceIdAgentThreadTests.EmptyObject))]
|
||||
[JsonSerializable(typeof(InMemoryChatMessageStoreTests.TestAIContent))]
|
||||
internal sealed partial class TestJsonSerializerContext : JsonSerializerContext;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Client.Entities;
|
||||
using Microsoft.DurableTask.Entities;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using OpenAI;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for scenarios where an external client interacts with Durable Task Agents.
|
||||
/// </summary>
|
||||
[Collection("Sequential")]
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class AgentEntityTests(ITestOutputHelper outputHelper) : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached
|
||||
? TimeSpan.FromMinutes(5)
|
||||
: TimeSpan.FromSeconds(30);
|
||||
|
||||
private static readonly IConfiguration s_configuration =
|
||||
new ConfigurationBuilder()
|
||||
.AddUserSecrets(Assembly.GetExecutingAssembly())
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
private readonly ITestOutputHelper _outputHelper = outputHelper;
|
||||
private readonly CancellationTokenSource _cts = new(delay: s_defaultTimeout);
|
||||
|
||||
private CancellationToken TestTimeoutToken => this._cts.Token;
|
||||
|
||||
public void Dispose() => this._cts.Dispose();
|
||||
|
||||
[Fact]
|
||||
public async Task EntityNamePrefixAsync()
|
||||
{
|
||||
// Setup
|
||||
AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent(
|
||||
name: "TestAgent",
|
||||
instructions: "You are a helpful assistant that always responds with a friendly greeting."
|
||||
);
|
||||
|
||||
using TestHelper testHelper = TestHelper.Start([simpleAgent], this._outputHelper);
|
||||
|
||||
// A proxy agent is needed to call the hosted test agent
|
||||
AIAgent simpleAgentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services);
|
||||
|
||||
AgentThread thread = simpleAgentProxy.GetNewThread();
|
||||
|
||||
DurableTaskClient client = testHelper.GetClient();
|
||||
|
||||
AgentSessionId sessionId = thread.GetService<AgentSessionId>();
|
||||
EntityInstanceId expectedEntityId = new($"dafx-{simpleAgent.Name}", sessionId.Key);
|
||||
|
||||
EntityMetadata? entity = await client.Entities.GetEntityAsync(expectedEntityId, false, this.TestTimeoutToken);
|
||||
|
||||
Assert.Null(entity);
|
||||
|
||||
// Act: send a prompt to the agent
|
||||
await simpleAgentProxy.RunAsync(
|
||||
message: "Hello!",
|
||||
thread,
|
||||
cancellationToken: this.TestTimeoutToken);
|
||||
|
||||
// Assert: verify the agent state was stored with the correct entity name prefix
|
||||
entity = await client.Entities.GetEntityAsync(expectedEntityId, false, this.TestTimeoutToken);
|
||||
|
||||
Assert.NotNull(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using OpenAI;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for scenarios where an external client interacts with Durable Task Agents.
|
||||
/// </summary>
|
||||
[Collection("Sequential")]
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class ExternalClientTests(ITestOutputHelper outputHelper) : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached
|
||||
? TimeSpan.FromMinutes(5)
|
||||
: TimeSpan.FromSeconds(30);
|
||||
|
||||
private static readonly IConfiguration s_configuration =
|
||||
new ConfigurationBuilder()
|
||||
.AddUserSecrets(Assembly.GetExecutingAssembly())
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
private readonly ITestOutputHelper _outputHelper = outputHelper;
|
||||
private readonly CancellationTokenSource _cts = new(delay: s_defaultTimeout);
|
||||
|
||||
private CancellationToken TestTimeoutToken => this._cts.Token;
|
||||
|
||||
public void Dispose() => this._cts.Dispose();
|
||||
|
||||
[Fact]
|
||||
public async Task SimplePromptAsync()
|
||||
{
|
||||
// Setup
|
||||
AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent(
|
||||
instructions: "You are a helpful assistant that always responds with a friendly greeting.",
|
||||
name: "TestAgent");
|
||||
|
||||
using TestHelper testHelper = TestHelper.Start([simpleAgent], this._outputHelper);
|
||||
|
||||
// A proxy agent is needed to call the hosted test agent
|
||||
AIAgent simpleAgentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services);
|
||||
|
||||
// Act: send a prompt to the agent and wait for a response
|
||||
AgentThread thread = simpleAgentProxy.GetNewThread();
|
||||
await simpleAgentProxy.RunAsync(
|
||||
message: "Hello!",
|
||||
thread,
|
||||
cancellationToken: this.TestTimeoutToken);
|
||||
|
||||
AgentRunResponse response = await simpleAgentProxy.RunAsync(
|
||||
message: "Repeat what you just said but say it like a pirate",
|
||||
thread,
|
||||
cancellationToken: this.TestTimeoutToken);
|
||||
|
||||
// Assert: verify the agent responded appropriately
|
||||
// We can't predict the exact response, but we can check that there is one response
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Text);
|
||||
|
||||
// Assert: verify the expected log entries were created in the expected category
|
||||
IReadOnlyCollection<LogEntry> logs = testHelper.GetLogs();
|
||||
Assert.NotEmpty(logs);
|
||||
List<LogEntry> agentLogs = [.. logs.Where(log => log.Category.Contains(simpleAgent.Name!)).ToList()];
|
||||
Assert.NotEmpty(agentLogs);
|
||||
Assert.Contains(agentLogs, log => log.EventId.Name == "LogAgentRequest" && log.Message.Contains("Hello!"));
|
||||
Assert.Contains(agentLogs, log => log.EventId.Name == "LogAgentResponse");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallFunctionToolsAsync()
|
||||
{
|
||||
int weatherToolInvocationCount = 0;
|
||||
int packingListToolInvocationCount = 0;
|
||||
|
||||
string GetWeather(string location)
|
||||
{
|
||||
weatherToolInvocationCount++;
|
||||
return $"The weather in {location} is sunny with a high of 75°F and a low of 55°F.";
|
||||
}
|
||||
|
||||
string SuggestPackingList(string weather, bool isSunny)
|
||||
{
|
||||
packingListToolInvocationCount++;
|
||||
return isSunny ? "Pack sunglasses and sunscreen." : "Pack a raincoat and umbrella.";
|
||||
}
|
||||
|
||||
AIAgent tripPlanningAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent(
|
||||
instructions: "You are a trip planning assistant. Use the weather tool and packing list tool as needed.",
|
||||
name: "TripPlanningAgent",
|
||||
description: "An agent to help plan your day trips",
|
||||
tools: [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(SuggestPackingList)]
|
||||
);
|
||||
|
||||
using TestHelper testHelper = TestHelper.Start([tripPlanningAgent], this._outputHelper);
|
||||
AIAgent tripPlanningAgentProxy = tripPlanningAgent.AsDurableAgentProxy(testHelper.Services);
|
||||
|
||||
// Act: send a prompt to the agent
|
||||
AgentRunResponse response = await tripPlanningAgentProxy.RunAsync(
|
||||
message: "Help me figure out what to pack for my Seattle trip next Sunday",
|
||||
cancellationToken: this.TestTimeoutToken);
|
||||
|
||||
// Assert: verify the agent responded appropriately
|
||||
// We can't predict the exact response, but we can check that there is one response
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Text);
|
||||
|
||||
// Assert: verify the expected log entries were created in the expected category
|
||||
IReadOnlyCollection<LogEntry> logs = testHelper.GetLogs();
|
||||
Assert.NotEmpty(logs);
|
||||
|
||||
List<LogEntry> agentLogs = [.. logs.Where(log => log.Category.Contains(tripPlanningAgent.Name!)).ToList()];
|
||||
Assert.NotEmpty(agentLogs);
|
||||
Assert.Contains(agentLogs, log => log.EventId.Name == "LogAgentRequest" && log.Message.Contains("Seattle trip"));
|
||||
Assert.Contains(agentLogs, log => log.EventId.Name == "LogAgentResponse");
|
||||
|
||||
// Assert: verify the tools were called
|
||||
Assert.Equal(1, weatherToolInvocationCount);
|
||||
Assert.Equal(1, packingListToolInvocationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallLongRunningFunctionToolsAsync()
|
||||
{
|
||||
[Description("Starts a greeting workflow and returns the workflow instance ID")]
|
||||
string StartWorkflowTool(string name)
|
||||
{
|
||||
return DurableAgentContext.Current.ScheduleNewOrchestration(nameof(RunWorkflowAsync), input: name);
|
||||
}
|
||||
|
||||
[Description("Gets the current status of a previously started workflow. A null response means the workflow has not started yet.")]
|
||||
static async Task<OrchestrationMetadata?> GetWorkflowStatusToolAsync(string instanceId)
|
||||
{
|
||||
OrchestrationMetadata? status = await DurableAgentContext.Current.GetOrchestrationStatusAsync(
|
||||
instanceId,
|
||||
includeDetails: true);
|
||||
if (status == null)
|
||||
{
|
||||
// If the status is not found, wait a bit before returning null to give the workflow time to start
|
||||
await Task.Delay(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
async Task<string> RunWorkflowAsync(TaskOrchestrationContext context, string name)
|
||||
{
|
||||
// 1. Get agent and create a session
|
||||
DurableAIAgent agent = context.GetAgent("SimpleAgent");
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
// 2. Call an agent and tell it my name
|
||||
await agent.RunAsync($"My name is {name}.", thread);
|
||||
|
||||
// 3. Call the agent again with the same thread (ask it to tell me my name)
|
||||
AgentRunResponse response = await agent.RunAsync("What is my name?", thread);
|
||||
|
||||
return response.Text;
|
||||
}
|
||||
|
||||
using TestHelper testHelper = TestHelper.Start(
|
||||
this._outputHelper,
|
||||
configureAgents: agents =>
|
||||
{
|
||||
// This is the agent that will be used to start the workflow
|
||||
agents.AddAIAgentFactory(
|
||||
"WorkflowAgent",
|
||||
sp => TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent(
|
||||
name: "WorkflowAgent",
|
||||
instructions: "You can start greeting workflows and check their status.",
|
||||
services: sp,
|
||||
tools: [
|
||||
AIFunctionFactory.Create(StartWorkflowTool),
|
||||
AIFunctionFactory.Create(GetWorkflowStatusToolAsync)
|
||||
]));
|
||||
|
||||
// This is the agent that will be called by the workflow
|
||||
agents.AddAIAgent(TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent(
|
||||
name: "SimpleAgent",
|
||||
instructions: "You are a simple assistant."
|
||||
));
|
||||
},
|
||||
durableTaskRegistry: registry => registry.AddOrchestratorFunc<string, string>(nameof(RunWorkflowAsync), RunWorkflowAsync));
|
||||
|
||||
AIAgent workflowManagerAgentProxy = testHelper.Services.GetDurableAgentProxy("WorkflowAgent");
|
||||
|
||||
// Act: send a prompt to the agent
|
||||
AgentThread thread = workflowManagerAgentProxy.GetNewThread();
|
||||
await workflowManagerAgentProxy.RunAsync(
|
||||
message: "Start a greeting workflow for \"John Doe\".",
|
||||
thread,
|
||||
cancellationToken: this.TestTimeoutToken);
|
||||
|
||||
// Act: prompt it again to wait for the workflow to complete
|
||||
AgentRunResponse response = await workflowManagerAgentProxy.RunAsync(
|
||||
message: "Wait for the workflow to complete and tell me the result.",
|
||||
thread,
|
||||
cancellationToken: this.TestTimeoutToken);
|
||||
|
||||
// Assert: verify the agent responded appropriately
|
||||
// We can't predict the exact response, but we can check that there is one response
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Text);
|
||||
Assert.Contains("John Doe", response.Text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging;
|
||||
|
||||
internal sealed class LogEntry(
|
||||
string category,
|
||||
LogLevel level,
|
||||
EventId eventId,
|
||||
Exception? exception,
|
||||
string message,
|
||||
object? state,
|
||||
IReadOnlyList<KeyValuePair<string, object?>> contextProperties)
|
||||
{
|
||||
public string Category { get; } = category;
|
||||
|
||||
public DateTime Timestamp { get; } = DateTime.Now;
|
||||
|
||||
public EventId EventId { get; } = eventId;
|
||||
|
||||
public LogLevel LogLevel { get; } = level;
|
||||
|
||||
public Exception? Exception { get; } = exception;
|
||||
|
||||
public string Message { get; } = message;
|
||||
|
||||
public object? State { get; } = state;
|
||||
|
||||
public IReadOnlyList<KeyValuePair<string, object?>> ContextProperties { get; } = contextProperties;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
string properties = this.ContextProperties.Count > 0
|
||||
? $"[{string.Join(", ", this.ContextProperties.Select(kvp => $"{kvp.Key}={kvp.Value}"))}] "
|
||||
: string.Empty;
|
||||
|
||||
string eventName = this.EventId.Name ?? string.Empty;
|
||||
string output = $"{this.Timestamp:o} [{this.Category}] {eventName} {properties}{this.Message}";
|
||||
|
||||
if (this.Exception is not null)
|
||||
{
|
||||
output += Environment.NewLine + this.Exception;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging;
|
||||
|
||||
internal sealed class TestLogger(string category, ITestOutputHelper output) : ILogger
|
||||
{
|
||||
private readonly string _category = category;
|
||||
private readonly ITestOutputHelper _output = output;
|
||||
private readonly ConcurrentQueue<LogEntry> _entries = new();
|
||||
|
||||
public IReadOnlyCollection<LogEntry> GetLogs() => this._entries;
|
||||
|
||||
public void ClearLogs() => this._entries.Clear();
|
||||
|
||||
IDisposable? ILogger.BeginScope<TState>(TState state) => null;
|
||||
|
||||
bool ILogger.IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
void ILogger.Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
LogEntry entry = new(
|
||||
category: this._category,
|
||||
level: logLevel,
|
||||
eventId: eventId,
|
||||
exception: exception,
|
||||
message: formatter(state, exception),
|
||||
state: state,
|
||||
contextProperties: []);
|
||||
|
||||
this._entries.Enqueue(entry);
|
||||
|
||||
try
|
||||
{
|
||||
this._output.WriteLine(entry.ToString());
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Expected when tests are shutting down
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging;
|
||||
|
||||
internal sealed class TestLoggerProvider(ITestOutputHelper output) : ILoggerProvider
|
||||
{
|
||||
private readonly ITestOutputHelper _output = output ?? throw new ArgumentNullException(nameof(output));
|
||||
private readonly ConcurrentDictionary<string, TestLogger> _loggers = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public bool TryGetLogs(string category, out IReadOnlyCollection<LogEntry> logs)
|
||||
{
|
||||
if (this._loggers.TryGetValue(category, out TestLogger? logger))
|
||||
{
|
||||
logs = logger.GetLogs();
|
||||
return true;
|
||||
}
|
||||
|
||||
logs = [];
|
||||
return false;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<LogEntry> GetAllLogs()
|
||||
{
|
||||
return this._loggers.Values
|
||||
.OfType<TestLogger>()
|
||||
.SelectMany(logger => logger.GetLogs())
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
foreach (TestLogger logger in this._loggers.Values.OfType<TestLogger>())
|
||||
{
|
||||
logger.ClearLogs();
|
||||
}
|
||||
}
|
||||
|
||||
ILogger ILoggerProvider.CreateLogger(string categoryName)
|
||||
{
|
||||
return this._loggers.GetOrAdd(categoryName, _ => new TestLogger(categoryName, this._output));
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>b7762d10-e29b-4bb1-8b74-b6d69a667dd4</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Public packages required by integration tests -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.DurableTask.IntegrationTests.Logging;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenAI.Chat;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
|
||||
|
||||
internal sealed class TestHelper : IDisposable
|
||||
{
|
||||
private readonly TestLoggerProvider _loggerProvider;
|
||||
private readonly IHost _host;
|
||||
private readonly DurableTaskClient _client;
|
||||
|
||||
// The static Start method should be used to create instances of this class.
|
||||
private TestHelper(
|
||||
TestLoggerProvider loggerProvider,
|
||||
IHost host,
|
||||
DurableTaskClient client)
|
||||
{
|
||||
this._loggerProvider = loggerProvider;
|
||||
this._host = host;
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
public IServiceProvider Services => this._host.Services;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._host.Dispose();
|
||||
}
|
||||
|
||||
public bool TryGetLogs(string category, out IReadOnlyCollection<LogEntry> logs)
|
||||
=> this._loggerProvider.TryGetLogs(category, out logs);
|
||||
|
||||
public static TestHelper Start(
|
||||
AIAgent[] agents,
|
||||
ITestOutputHelper outputHelper,
|
||||
Action<DurableTaskRegistry>? durableTaskRegistry = null)
|
||||
{
|
||||
return BuildAndStartTestHelper(
|
||||
outputHelper,
|
||||
options => options.AddAIAgents(agents),
|
||||
durableTaskRegistry);
|
||||
}
|
||||
|
||||
public static TestHelper Start(
|
||||
ITestOutputHelper outputHelper,
|
||||
Action<DurableAgentsOptions> configureAgents,
|
||||
Action<DurableTaskRegistry>? durableTaskRegistry = null)
|
||||
{
|
||||
return BuildAndStartTestHelper(
|
||||
outputHelper,
|
||||
configureAgents,
|
||||
durableTaskRegistry);
|
||||
}
|
||||
|
||||
public DurableTaskClient GetClient() => this._client;
|
||||
|
||||
private static TestHelper BuildAndStartTestHelper(
|
||||
ITestOutputHelper outputHelper,
|
||||
Action<DurableAgentsOptions> configureAgents,
|
||||
Action<DurableTaskRegistry>? durableTaskRegistry)
|
||||
{
|
||||
TestLoggerProvider loggerProvider = new(outputHelper);
|
||||
|
||||
IHost host = Host.CreateDefaultBuilder()
|
||||
.ConfigureServices((ctx, services) =>
|
||||
{
|
||||
string dtsConnectionString = GetDurableTaskSchedulerConnectionString(ctx.Configuration);
|
||||
|
||||
// Register durable agents using the caller-supplied registration action and
|
||||
// apply the default chat client for agents that don't supply one themselves.
|
||||
services.ConfigureDurableAgents(
|
||||
options => configureAgents(options),
|
||||
workerBuilder: builder =>
|
||||
{
|
||||
builder.UseDurableTaskScheduler(dtsConnectionString);
|
||||
if (durableTaskRegistry != null)
|
||||
{
|
||||
builder.AddTasks(durableTaskRegistry);
|
||||
}
|
||||
},
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.ConfigureLogging((_, logging) =>
|
||||
{
|
||||
logging.AddProvider(loggerProvider);
|
||||
logging.SetMinimumLevel(LogLevel.Debug);
|
||||
})
|
||||
.Build();
|
||||
host.Start();
|
||||
|
||||
DurableTaskClient client = host.Services.GetRequiredService<DurableTaskClient>();
|
||||
return new TestHelper(loggerProvider, host, client);
|
||||
}
|
||||
|
||||
private static string GetDurableTaskSchedulerConnectionString(IConfiguration configuration)
|
||||
{
|
||||
// The default value is for local development using the Durable Task Scheduler emulator.
|
||||
return configuration["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"]
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
}
|
||||
|
||||
internal static ChatClient GetAzureOpenAIChatClient(IConfiguration configuration)
|
||||
{
|
||||
string azureOpenAiEndpoint = configuration["AZURE_OPENAI_ENDPOINT"] ??
|
||||
throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set.");
|
||||
string azureOpenAiDeploymentName = configuration["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] ??
|
||||
throw new InvalidOperationException("The required AZURE_OPENAI_CHAT_DEPLOYMENT_NAME env variable is not set.");
|
||||
|
||||
// Check if AZURE_OPENAI_KEY is provided for key-based authentication.
|
||||
// NOTE: This is not used for automated tests, but can be useful for local development.
|
||||
string? azureOpenAiKey = configuration["AZURE_OPENAI_KEY"];
|
||||
|
||||
AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey)
|
||||
? new AzureOpenAIClient(new Uri(azureOpenAiEndpoint), new AzureKeyCredential(azureOpenAiKey))
|
||||
: new AzureOpenAIClient(new Uri(azureOpenAiEndpoint), new AzureCliCredential());
|
||||
|
||||
return client.GetChatClient(azureOpenAiDeploymentName);
|
||||
}
|
||||
|
||||
internal IReadOnlyCollection<LogEntry> GetLogs()
|
||||
{
|
||||
return this._loggerProvider.GetAllLogs();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.DurableTask.Entities;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.UnitTests;
|
||||
|
||||
public sealed class AgentSessionIdTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseValidSessionId()
|
||||
{
|
||||
const string Name = "test-agent";
|
||||
const string Key = "12345";
|
||||
string sessionIdString = $"@dafx-{Name}@{Key}";
|
||||
AgentSessionId sessionId = AgentSessionId.Parse(sessionIdString);
|
||||
|
||||
Assert.Equal(Name, sessionId.Name);
|
||||
Assert.Equal(Key, sessionId.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseInvalidSessionId()
|
||||
{
|
||||
const string InvalidSessionIdString = "@test-agent@12345"; // Missing "dafx-" prefix
|
||||
Assert.Throws<ArgumentException>(() => AgentSessionId.Parse(InvalidSessionIdString));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromEntityId()
|
||||
{
|
||||
const string Name = "test-agent";
|
||||
const string Key = "12345";
|
||||
|
||||
EntityInstanceId entityId = new($"dafx-{Name}", Key);
|
||||
AgentSessionId sessionId = (AgentSessionId)entityId;
|
||||
|
||||
Assert.Equal(Name, sessionId.Name);
|
||||
Assert.Equal(Key, sessionId.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromInvalidEntityId()
|
||||
{
|
||||
const string Name = "test-agent";
|
||||
const string Key = "12345";
|
||||
|
||||
EntityInstanceId entityId = new(Name, Key); // Missing "dafx-" prefix
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
{
|
||||
// This assignment should throw an exception because
|
||||
// the entity ID is not a valid agent session ID.
|
||||
AgentSessionId sessionId = entityId;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.UnitTests;
|
||||
|
||||
public sealed class DurableAgentThreadTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuiltInSerialization()
|
||||
{
|
||||
AgentSessionId sessionId = AgentSessionId.WithRandomKey("test-agent");
|
||||
AgentThread thread = new DurableAgentThread(sessionId);
|
||||
|
||||
JsonElement serializedThread = thread.Serialize();
|
||||
|
||||
// Expected format: "{\"sessionId\":\"@dafx-test-agent@<random-key>\"}"
|
||||
string expectedSerializedThread = $"{{\"sessionId\":\"@dafx-{sessionId.Name}@{sessionId.Key}\"}}";
|
||||
Assert.Equal(expectedSerializedThread, serializedThread.ToString());
|
||||
|
||||
DurableAgentThread deserializedThread = DurableAgentThread.Deserialize(serializedThread);
|
||||
Assert.Equal(sessionId, deserializedThread.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void STJSerialization()
|
||||
{
|
||||
AgentSessionId sessionId = AgentSessionId.WithRandomKey("test-agent");
|
||||
AgentThread thread = new DurableAgentThread(sessionId);
|
||||
|
||||
// Need to specify the type explicitly because STJ, unlike other serializers,
|
||||
// does serialization based on the static type of the object, not the runtime type.
|
||||
string serializedThread = JsonSerializer.Serialize(thread, typeof(DurableAgentThread));
|
||||
|
||||
// Expected format: "{\"sessionId\":\"@dafx-test-agent@<random-key>\"}"
|
||||
string expectedSerializedThread = $"{{\"sessionId\":\"@dafx-{sessionId.Name}@{sessionId.Key}\"}}";
|
||||
Assert.Equal(expectedSerializedThread, serializedThread);
|
||||
|
||||
DurableAgentThread? deserializedThread = JsonSerializer.Deserialize<DurableAgentThread>(serializedThread);
|
||||
Assert.NotNull(deserializedThread);
|
||||
Assert.Equal(sessionId, deserializedThread.SessionId);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>b7762d10-e29b-4bb1-8b74-b6d69a667dd4</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State;
|
||||
|
||||
public sealed class DurableAgentStateContentTests
|
||||
{
|
||||
private static readonly JsonTypeInfo s_stateContentTypeInfo =
|
||||
DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateContent))!;
|
||||
|
||||
[Fact]
|
||||
public void ErrorContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
ErrorContent errorContent = new("message")
|
||||
{
|
||||
Details = "details",
|
||||
ErrorCode = "code"
|
||||
};
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(errorContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
ErrorContent convertedErrorContent = Assert.IsType<ErrorContent>(convertedContent);
|
||||
|
||||
Assert.Equal(errorContent.Message, convertedErrorContent.Message);
|
||||
Assert.Equal(errorContent.Details, convertedErrorContent.Details);
|
||||
Assert.Equal(errorContent.ErrorCode, convertedErrorContent.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
TextContent textContent = new("Hello, world!");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(textContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
TextContent convertedTextContent = Assert.IsType<TextContent>(convertedContent);
|
||||
|
||||
Assert.Equal(textContent.Text, convertedTextContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FunctionCallContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
FunctionCallContent functionCallContent = new(
|
||||
"call-123",
|
||||
"MyFunction",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
{ "param1", 42 },
|
||||
{ "param2", "value" }
|
||||
});
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(functionCallContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
FunctionCallContent convertedFunctionCallContent = Assert.IsType<FunctionCallContent>(convertedContent);
|
||||
|
||||
Assert.Equal(functionCallContent.CallId, convertedFunctionCallContent.CallId);
|
||||
Assert.Equal(functionCallContent.Name, convertedFunctionCallContent.Name);
|
||||
|
||||
Assert.NotNull(functionCallContent.Arguments);
|
||||
Assert.NotNull(convertedFunctionCallContent.Arguments);
|
||||
Assert.Equal(functionCallContent.Arguments.Keys.Order(), convertedFunctionCallContent.Arguments.Keys.Order());
|
||||
|
||||
// NOTE: Deserialized dictionaries will have JSON element values rather than the original native types,
|
||||
// so we only check the keys here.
|
||||
foreach (string key in functionCallContent.Arguments.Keys)
|
||||
{
|
||||
Assert.Equal(
|
||||
JsonSerializer.Serialize(functionCallContent.Arguments[key]),
|
||||
JsonSerializer.Serialize(convertedFunctionCallContent.Arguments[key]));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FunctionResultContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
FunctionResultContent functionResultContent = new("call-123", "return value");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(functionResultContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
FunctionResultContent convertedFunctionResultContent = Assert.IsType<FunctionResultContent>(convertedContent);
|
||||
|
||||
Assert.Equal(functionResultContent.CallId, convertedFunctionResultContent.CallId);
|
||||
// NOTE: We serialize both results to JSON for comparison since deserialized objects will be
|
||||
// JSON elements rather than the original native types.
|
||||
Assert.Equal(
|
||||
JsonSerializer.Serialize(functionResultContent.Result),
|
||||
JsonSerializer.Serialize(convertedFunctionResultContent.Result));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==", null)] // Valid data URI containing media type; pass null for separate mediaType parameter.
|
||||
[InlineData("data:;base64,SGVsbG8sIFdvcmxkIQ==", "text/plain")] // Valid data URI without media type; pass media
|
||||
public void DataContentSerializationDeserialization(string dataUri, string? mediaType)
|
||||
{
|
||||
// Arrange
|
||||
DataContent dataContent = new(dataUri, mediaType);
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(dataContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
DataContent convertedDataContent = Assert.IsType<DataContent>(convertedContent);
|
||||
|
||||
Assert.Equal(dataContent.Uri, convertedDataContent.Uri);
|
||||
Assert.Equal(dataContent.MediaType, convertedDataContent.MediaType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostedFileContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
HostedFileContent hostedFileContent = new("file-123");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(hostedFileContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
HostedFileContent convertedHostedFileContent = Assert.IsType<HostedFileContent>(convertedContent);
|
||||
|
||||
Assert.Equal(hostedFileContent.FileId, convertedHostedFileContent.FileId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostedVectorStoreContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
HostedVectorStoreContent hostedVectorStoreContent = new("vs-123");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(hostedVectorStoreContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
HostedVectorStoreContent convertedHostedVectorStoreContent = Assert.IsType<HostedVectorStoreContent>(convertedContent);
|
||||
|
||||
Assert.Equal(hostedVectorStoreContent.VectorStoreId, convertedHostedVectorStoreContent.VectorStoreId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextReasoningContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
TextReasoningContent textReasoningContent = new("Reasoning chain...");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(textReasoningContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
TextReasoningContent convertedTextReasoningContent = Assert.IsType<TextReasoningContent>(convertedContent);
|
||||
|
||||
Assert.Equal(textReasoningContent.Text, convertedTextReasoningContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UriContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
UriContent uriContent = new(new Uri("https://example.com"), "text/html");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(uriContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
UriContent convertedUriContent = Assert.IsType<UriContent>(convertedContent);
|
||||
|
||||
Assert.Equal(uriContent.Uri, convertedUriContent.Uri);
|
||||
Assert.Equal(uriContent.MediaType, convertedUriContent.MediaType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UsageContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
UsageDetails usageDetails = new()
|
||||
{
|
||||
InputTokenCount = 10,
|
||||
OutputTokenCount = 5,
|
||||
TotalTokenCount = 15
|
||||
};
|
||||
|
||||
UsageContent usageContent = new(usageDetails);
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateContent.FromAIContent(usageContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
UsageContent convertedUsageContent = Assert.IsType<UsageContent>(convertedContent);
|
||||
|
||||
Assert.NotNull(convertedUsageContent.Details);
|
||||
Assert.Equal(usageDetails.InputTokenCount, convertedUsageContent.Details.InputTokenCount);
|
||||
Assert.Equal(usageDetails.OutputTokenCount, convertedUsageContent.Details.OutputTokenCount);
|
||||
Assert.Equal(usageDetails.TotalTokenCount, convertedUsageContent.Details.TotalTokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownContentSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
TextContent originalContent = new("Some unknown content");
|
||||
|
||||
DurableAgentStateContent durableContent = DurableAgentStateUnknownContent.FromUnknownContent(originalContent);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(durableContent, s_stateContentTypeInfo);
|
||||
|
||||
DurableAgentStateContent? convertedJsonContent =
|
||||
(DurableAgentStateContent?)JsonSerializer.Deserialize(jsonContent, s_stateContentTypeInfo);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
AIContent convertedContent = convertedJsonContent.ToAIContent();
|
||||
|
||||
TextContent convertedTextContent = Assert.IsType<TextContent>(convertedContent);
|
||||
|
||||
Assert.Equal(originalContent.Text, convertedTextContent.Text);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State;
|
||||
|
||||
public sealed class DurableAgentStateMessageTests
|
||||
{
|
||||
[Fact]
|
||||
public void MessageSerializationDeserialization()
|
||||
{
|
||||
// Arrange
|
||||
TextContent textContent = new("Hello, world!");
|
||||
ChatMessage message = new(ChatRole.User, [textContent])
|
||||
{
|
||||
AuthorName = "User123",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
DurableAgentStateMessage durableMessage = DurableAgentStateMessage.FromChatMessage(message);
|
||||
|
||||
// Act
|
||||
string jsonContent = JsonSerializer.Serialize(
|
||||
durableMessage,
|
||||
DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateMessage))!);
|
||||
|
||||
DurableAgentStateMessage? convertedJsonContent = (DurableAgentStateMessage?)JsonSerializer.Deserialize(
|
||||
jsonContent,
|
||||
DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateMessage))!);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(convertedJsonContent);
|
||||
|
||||
ChatMessage convertedMessage = convertedJsonContent.ToChatMessage();
|
||||
|
||||
Assert.Equal(message.AuthorName, convertedMessage.AuthorName);
|
||||
Assert.Equal(message.CreatedAt, convertedMessage.CreatedAt);
|
||||
Assert.Equal(message.Role, convertedMessage.Role);
|
||||
|
||||
AIContent convertedContent = Assert.Single(convertedMessage.Contents);
|
||||
TextContent convertedTextContent = Assert.IsType<TextContent>(convertedContent);
|
||||
|
||||
Assert.Equal(textContent.Text, convertedTextContent.Text);
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State;
|
||||
|
||||
public sealed class DurableAgentStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void InvalidVersion()
|
||||
{
|
||||
// Arrange
|
||||
const string JsonText = """
|
||||
{
|
||||
"schemaVersion": "hello"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BreakingVersion()
|
||||
{
|
||||
// Arrange
|
||||
const string JsonText = """
|
||||
{
|
||||
"schemaVersion": "2.0.0"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingData()
|
||||
{
|
||||
// Arrange
|
||||
const string JsonText = """
|
||||
{
|
||||
"schemaVersion": "1.0.0"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtraData()
|
||||
{
|
||||
// Arrange
|
||||
const string JsonText = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"data": {
|
||||
"conversationHistory": [],
|
||||
"extraField": "someValue"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
DurableAgentState? state = JsonSerializer.Deserialize(JsonText, DurableAgentStateJsonContext.Default.DurableAgentState);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state?.Data?.ExtensionData);
|
||||
|
||||
Assert.True(state.Data.ExtensionData!.ContainsKey("extraField"));
|
||||
Assert.Equal("someValue", state.Data.ExtensionData["extraField"]!.ToString());
|
||||
|
||||
// Act
|
||||
string jsonState = JsonSerializer.Serialize(state, DurableAgentStateJsonContext.Default.DurableAgentState);
|
||||
JsonDocument? jsonDocument = JsonSerializer.Deserialize<JsonDocument>(jsonState);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(jsonDocument);
|
||||
Assert.True(jsonDocument.RootElement.TryGetProperty("data", out JsonElement dataElement));
|
||||
Assert.True(dataElement.TryGetProperty("extraField", out JsonElement extraFieldElement));
|
||||
Assert.Equal("someValue", extraFieldElement.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicState()
|
||||
{
|
||||
// Arrange
|
||||
const string JsonText = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"data": {
|
||||
"conversationHistory": [
|
||||
{
|
||||
"$type": "request",
|
||||
"correlationId": "12345",
|
||||
"createdAt": "2024-01-01T12:00:00Z",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"contents": [
|
||||
{
|
||||
"$type": "text",
|
||||
"text": "Hello, agent!"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"$type": "response",
|
||||
"correlationId": "12345",
|
||||
"createdAt": "2024-01-01T12:01:00Z",
|
||||
"messages": [
|
||||
{
|
||||
"role": "agent",
|
||||
"contents": [
|
||||
{
|
||||
"$type": "text",
|
||||
"text": "Hi user!"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
DurableAgentState? state = JsonSerializer.Deserialize(
|
||||
JsonText,
|
||||
DurableAgentStateJsonContext.Default.DurableAgentState);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal("1.0.0", state.SchemaVersion);
|
||||
Assert.NotNull(state.Data);
|
||||
|
||||
Assert.Collection(state.Data.ConversationHistory,
|
||||
entry =>
|
||||
{
|
||||
Assert.IsType<DurableAgentStateRequest>(entry);
|
||||
Assert.Equal("12345", entry.CorrelationId);
|
||||
Assert.Equal(DateTimeOffset.Parse("2024-01-01T12:00:00Z"), entry.CreatedAt);
|
||||
Assert.Single(entry.Messages);
|
||||
Assert.Equal("user", entry.Messages[0].Role);
|
||||
DurableAgentStateContent content = Assert.Single(entry.Messages[0].Contents);
|
||||
DurableAgentStateTextContent textContent = Assert.IsType<DurableAgentStateTextContent>(content);
|
||||
Assert.Equal("Hello, agent!", textContent.Text);
|
||||
},
|
||||
entry =>
|
||||
{
|
||||
Assert.IsType<DurableAgentStateResponse>(entry);
|
||||
Assert.Equal("12345", entry.CorrelationId);
|
||||
Assert.Equal(DateTimeOffset.Parse("2024-01-01T12:01:00Z"), entry.CreatedAt);
|
||||
Assert.Single(entry.Messages);
|
||||
Assert.Equal("agent", entry.Messages[0].Role);
|
||||
Assert.Single(entry.Messages[0].Contents);
|
||||
DurableAgentStateContent content = Assert.Single(entry.Messages[0].Contents);
|
||||
DurableAgentStateTextContent textContent = Assert.IsType<DurableAgentStateTextContent>(content);
|
||||
Assert.Equal("Hi user!", textContent.Text);
|
||||
});
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>b7762d10-e29b-4bb1-8b74-b6d69a667dd4</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+816
@@ -0,0 +1,816 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests;
|
||||
|
||||
[Collection("Samples")]
|
||||
[Trait("Category", "SampleValidation")]
|
||||
public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLifetime
|
||||
{
|
||||
private const string AzureFunctionsPort = "7071";
|
||||
private const string AzuritePort = "10000";
|
||||
private const string DtsPort = "8080";
|
||||
|
||||
private static readonly string s_dotnetTargetFramework = GetTargetFramework();
|
||||
private static readonly HttpClient s_sharedHttpClient = new();
|
||||
private static readonly IConfiguration s_configuration =
|
||||
new ConfigurationBuilder()
|
||||
.AddUserSecrets(Assembly.GetExecutingAssembly())
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
private static bool s_infrastructureStarted;
|
||||
private static readonly TimeSpan s_orchestrationTimeout = TimeSpan.FromMinutes(1);
|
||||
private static readonly string s_samplesPath = Path.GetFullPath(
|
||||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "AzureFunctions"));
|
||||
|
||||
private readonly ITestOutputHelper _outputHelper = outputHelper;
|
||||
|
||||
async Task IAsyncLifetime.InitializeAsync()
|
||||
{
|
||||
if (!s_infrastructureStarted)
|
||||
{
|
||||
await this.StartSharedInfrastructureAsync();
|
||||
s_infrastructureStarted = true;
|
||||
}
|
||||
}
|
||||
|
||||
async Task IAsyncLifetime.DisposeAsync()
|
||||
{
|
||||
// Nothing to clean up
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SingleAgentSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "01_SingleAgent");
|
||||
await this.RunSampleTestAsync(samplePath, async (logs) =>
|
||||
{
|
||||
Uri startUri = new($"http://localhost:{AzureFunctionsPort}/api/agents/Joker/run");
|
||||
this._outputHelper.WriteLine($"Starting single agent orchestration via POST request to {startUri}...");
|
||||
|
||||
// Test the agent endpoint as described in the README
|
||||
const string RequestBody = "Tell me a joke about a pirate.";
|
||||
using HttpContent content = new StringContent(RequestBody, Encoding.UTF8, "text/plain");
|
||||
|
||||
using HttpResponseMessage response = await s_sharedHttpClient.PostAsync(startUri, content);
|
||||
|
||||
// The response is expected to be a plain text response with the agent's reply (the joke)
|
||||
Assert.True(response.IsSuccessStatusCode, $"Agent request failed with status: {response.StatusCode}");
|
||||
Assert.Equal("text/plain", response.Content.Headers.ContentType?.MediaType);
|
||||
string responseText = await response.Content.ReadAsStringAsync();
|
||||
Assert.NotEmpty(responseText);
|
||||
this._outputHelper.WriteLine($"Agent run response: {responseText}");
|
||||
|
||||
// The response headers should include the agent thread ID, which can be used to continue the conversation.
|
||||
string? threadId = response.Headers.GetValues("x-ms-thread-id")?.FirstOrDefault();
|
||||
Assert.NotNull(threadId);
|
||||
|
||||
this._outputHelper.WriteLine($"Agent thread ID: {threadId}");
|
||||
Assert.StartsWith("@dafx-joker@", threadId);
|
||||
|
||||
// Wait for up to 30 seconds to see if the agent response is available in the logs
|
||||
await this.WaitForConditionAsync(
|
||||
condition: () =>
|
||||
{
|
||||
lock (logs)
|
||||
{
|
||||
bool exists = logs.Any(
|
||||
log => log.Message.Contains("Response:") && log.Message.Contains(threadId));
|
||||
return Task.FromResult(exists);
|
||||
}
|
||||
},
|
||||
message: "Agent response is available",
|
||||
timeout: TimeSpan.FromSeconds(30));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SingleAgentOrchestrationChainingSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "02_AgentOrchestration_Chaining");
|
||||
await this.RunSampleTestAsync(samplePath, async (logs) =>
|
||||
{
|
||||
Uri startUri = new($"http://localhost:{AzureFunctionsPort}/api/singleagent/run");
|
||||
this._outputHelper.WriteLine($"Starting single agent orchestration via POST request to {startUri}...");
|
||||
|
||||
// Start the orchestration
|
||||
using HttpResponseMessage startResponse = await s_sharedHttpClient.PostAsync(startUri, content: null);
|
||||
|
||||
Assert.True(
|
||||
startResponse.IsSuccessStatusCode,
|
||||
$"Start orchestration failed with status: {startResponse.StatusCode}");
|
||||
string startResponseText = await startResponse.Content.ReadAsStringAsync();
|
||||
JsonElement startResult = JsonSerializer.Deserialize<JsonElement>(startResponseText);
|
||||
|
||||
Assert.True(startResult.TryGetProperty("statusQueryGetUri", out JsonElement statusUriElement));
|
||||
Uri statusUri = new(statusUriElement.GetString()!);
|
||||
|
||||
// Wait for orchestration to complete
|
||||
await this.WaitForOrchestrationCompletionAsync(statusUri);
|
||||
|
||||
// Verify the final result
|
||||
using HttpResponseMessage statusResponse = await s_sharedHttpClient.GetAsync(statusUri);
|
||||
Assert.True(
|
||||
statusResponse.IsSuccessStatusCode,
|
||||
$"Status check failed with status: {statusResponse.StatusCode}");
|
||||
|
||||
string statusText = await statusResponse.Content.ReadAsStringAsync();
|
||||
JsonElement statusResult = JsonSerializer.Deserialize<JsonElement>(statusText);
|
||||
|
||||
Assert.Equal("Completed", statusResult.GetProperty("runtimeStatus").GetString());
|
||||
Assert.True(statusResult.TryGetProperty("output", out JsonElement outputElement));
|
||||
string? output = outputElement.GetString();
|
||||
|
||||
// Can't really validate the output since it's non-deterministic, but we can at least check it's non-empty
|
||||
Assert.NotNull(output);
|
||||
Assert.True(output.Length > 20, "Output is unexpectedly short");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiAgentOrchestrationConcurrentSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "03_AgentOrchestration_Concurrency");
|
||||
await this.RunSampleTestAsync(samplePath, async (logs) =>
|
||||
{
|
||||
// Start the multi-agent orchestration
|
||||
const string RequestBody = "What is temperature?";
|
||||
using HttpContent content = new StringContent(RequestBody, Encoding.UTF8, "text/plain");
|
||||
|
||||
Uri startUri = new($"http://localhost:{AzureFunctionsPort}/api/multiagent/run");
|
||||
this._outputHelper.WriteLine($"Starting multi agent orchestration via POST request to {startUri}...");
|
||||
using HttpResponseMessage startResponse = await s_sharedHttpClient.PostAsync(startUri, content);
|
||||
|
||||
Assert.True(startResponse.IsSuccessStatusCode, $"Start orchestration failed with status: {startResponse.StatusCode}");
|
||||
string startResponseText = await startResponse.Content.ReadAsStringAsync();
|
||||
JsonElement startResult = JsonSerializer.Deserialize<JsonElement>(startResponseText);
|
||||
|
||||
Assert.True(startResult.TryGetProperty("instanceId", out JsonElement instanceIdElement));
|
||||
Assert.True(startResult.TryGetProperty("statusQueryGetUri", out JsonElement statusUriElement));
|
||||
|
||||
Uri statusUri = new(statusUriElement.GetString()!);
|
||||
|
||||
// Wait for orchestration to complete
|
||||
await this.WaitForOrchestrationCompletionAsync(statusUri);
|
||||
|
||||
// Verify the final result
|
||||
using HttpResponseMessage statusResponse = await s_sharedHttpClient.GetAsync(statusUri);
|
||||
Assert.True(statusResponse.IsSuccessStatusCode, $"Status check failed with status: {statusResponse.StatusCode}");
|
||||
|
||||
string statusText = await statusResponse.Content.ReadAsStringAsync();
|
||||
JsonElement statusResult = JsonSerializer.Deserialize<JsonElement>(statusText);
|
||||
|
||||
Assert.Equal("Completed", statusResult.GetProperty("runtimeStatus").GetString());
|
||||
Assert.True(statusResult.TryGetProperty("output", out JsonElement outputElement));
|
||||
|
||||
// Verify both physicist and chemist responses are present
|
||||
Assert.True(outputElement.TryGetProperty("physicist", out JsonElement physicistElement));
|
||||
Assert.True(outputElement.TryGetProperty("chemist", out JsonElement chemistElement));
|
||||
|
||||
string physicistResponse = physicistElement.GetString()!;
|
||||
string chemistResponse = chemistElement.GetString()!;
|
||||
|
||||
Assert.NotEmpty(physicistResponse);
|
||||
Assert.NotEmpty(chemistResponse);
|
||||
Assert.Contains("temperature", physicistResponse, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("temperature", chemistResponse, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiAgentOrchestrationConditionalsSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "04_AgentOrchestration_Conditionals");
|
||||
await this.RunSampleTestAsync(samplePath, async (logs) =>
|
||||
{
|
||||
// Test with legitimate email
|
||||
await this.TestSpamDetectionAsync("email-001",
|
||||
"Hi John, I hope you're doing well. I wanted to follow up on our meeting yesterday about the quarterly report. Could you please send me the updated figures by Friday? Thanks!",
|
||||
expectedSpam: false);
|
||||
|
||||
// Test with spam email
|
||||
await this.TestSpamDetectionAsync("email-002",
|
||||
"URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer! Don't miss out!",
|
||||
expectedSpam: true);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SingleAgentOrchestrationHITLSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "05_AgentOrchestration_HITL");
|
||||
|
||||
await this.RunSampleTestAsync(samplePath, async (logs) =>
|
||||
{
|
||||
// Start the HITL orchestration with short timeout for testing
|
||||
// TODO: Add validation for the approval case
|
||||
object requestBody = new
|
||||
{
|
||||
topic = "The Future of Artificial Intelligence",
|
||||
max_review_attempts = 3,
|
||||
approval_timeout_hours = 0.001 // Very short timeout for testing
|
||||
};
|
||||
|
||||
string jsonContent = JsonSerializer.Serialize(requestBody);
|
||||
using HttpContent content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
|
||||
|
||||
Uri startUri = new($"http://localhost:{AzureFunctionsPort}/api/hitl/run");
|
||||
this._outputHelper.WriteLine($"Starting HITL orchestration via POST request to {startUri}...");
|
||||
using HttpResponseMessage startResponse = await s_sharedHttpClient.PostAsync(startUri, content);
|
||||
|
||||
Assert.True(
|
||||
startResponse.IsSuccessStatusCode,
|
||||
$"Start HITL orchestration failed with status: {startResponse.StatusCode}");
|
||||
string startResponseText = await startResponse.Content.ReadAsStringAsync();
|
||||
JsonElement startResult = JsonSerializer.Deserialize<JsonElement>(startResponseText);
|
||||
|
||||
Assert.True(startResult.TryGetProperty("statusQueryGetUri", out JsonElement statusUriElement));
|
||||
Uri statusUri = new(statusUriElement.GetString()!);
|
||||
|
||||
// Wait for orchestration to complete (it should timeout due to short timeout)
|
||||
await this.WaitForOrchestrationCompletionAsync(statusUri);
|
||||
|
||||
// Verify the final result
|
||||
using HttpResponseMessage statusResponse = await s_sharedHttpClient.GetAsync(statusUri);
|
||||
Assert.True(
|
||||
statusResponse.IsSuccessStatusCode,
|
||||
$"Status check failed with status: {statusResponse.StatusCode}");
|
||||
|
||||
string statusText = await statusResponse.Content.ReadAsStringAsync();
|
||||
this._outputHelper.WriteLine($"HITL orchestration status text: {statusText}");
|
||||
|
||||
JsonElement statusResult = JsonSerializer.Deserialize<JsonElement>(statusText);
|
||||
|
||||
// The orchestration should complete with a failed status due to timeout
|
||||
Assert.Equal("Failed", statusResult.GetProperty("runtimeStatus").GetString());
|
||||
Assert.True(statusResult.TryGetProperty("failureDetails", out JsonElement failureDetailsElement));
|
||||
Assert.True(failureDetailsElement.TryGetProperty("ErrorType", out JsonElement errorTypeElement));
|
||||
Assert.Equal("System.TimeoutException", errorTypeElement.GetString());
|
||||
Assert.True(failureDetailsElement.TryGetProperty("ErrorMessage", out JsonElement errorMessageElement));
|
||||
Assert.StartsWith("Human approval timed out", errorMessageElement.GetString());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LongRunningToolsSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "06_LongRunningTools");
|
||||
|
||||
await this.RunSampleTestAsync(samplePath, async (logs) =>
|
||||
{
|
||||
// Test starting an agent that schedules a content generation orchestration
|
||||
const string Prompt = "Start a content generation workflow for the topic 'The Future of Artificial Intelligence'";
|
||||
using HttpContent messageContent = new StringContent(Prompt, Encoding.UTF8, "text/plain");
|
||||
|
||||
Uri runAgentUri = new($"http://localhost:{AzureFunctionsPort}/api/agents/publisher/run");
|
||||
|
||||
this._outputHelper.WriteLine($"Starting agent tool orchestration via POST request to {runAgentUri}...");
|
||||
using HttpResponseMessage startResponse = await s_sharedHttpClient.PostAsync(runAgentUri, messageContent);
|
||||
|
||||
Assert.True(
|
||||
startResponse.IsSuccessStatusCode,
|
||||
$"Start agent request failed with status: {startResponse.StatusCode}");
|
||||
|
||||
string startResponseText = await startResponse.Content.ReadAsStringAsync();
|
||||
this._outputHelper.WriteLine($"Agent response: {startResponseText}");
|
||||
|
||||
// The response should be deserializable as an AgentRunResponse object and have a valid thread ID
|
||||
startResponse.Headers.TryGetValues("x-ms-thread-id", out IEnumerable<string>? agentIdValues);
|
||||
string? threadId = agentIdValues?.FirstOrDefault();
|
||||
Assert.NotNull(threadId);
|
||||
Assert.StartsWith("@dafx-publisher@", threadId);
|
||||
|
||||
// Wait for the orchestration to report that it's waiting for human approval
|
||||
await this.WaitForConditionAsync(
|
||||
condition: () =>
|
||||
{
|
||||
// For now, we have to rely on the logs to check for the "NOTIFICATION" message that gets generated by the activity function.
|
||||
// TODO: Synchronously prompt the agent for status
|
||||
lock (logs)
|
||||
{
|
||||
bool exists = logs.Any(log => log.Message.Contains("NOTIFICATION: Please review the following content for approval"));
|
||||
return Task.FromResult(exists);
|
||||
}
|
||||
},
|
||||
message: "Orchestration is requesting human feedback",
|
||||
timeout: TimeSpan.FromSeconds(60));
|
||||
|
||||
// Approve the content
|
||||
Uri approvalUri = new($"{runAgentUri}?thread_id={threadId}");
|
||||
using HttpContent approvalContent = new StringContent("Approve the content", Encoding.UTF8, "text/plain");
|
||||
using HttpResponseMessage approvalResponse = await s_sharedHttpClient.PostAsync(approvalUri, approvalContent);
|
||||
Assert.True(approvalResponse.IsSuccessStatusCode, $"Approve content request failed with status: {approvalResponse.StatusCode}");
|
||||
|
||||
// Wait for the publish notification to be logged
|
||||
await this.WaitForConditionAsync(
|
||||
condition: () =>
|
||||
{
|
||||
lock (logs)
|
||||
{
|
||||
// TODO: Synchronously prompt the agent for status
|
||||
bool exists = logs.Any(log => log.Message.Contains("PUBLISHING: Content has been published successfully"));
|
||||
return Task.FromResult(exists);
|
||||
}
|
||||
},
|
||||
message: "Content published notification is logged",
|
||||
timeout: TimeSpan.FromSeconds(60));
|
||||
|
||||
// Verify the final orchestration status by asking the agent for the status
|
||||
Uri statusUri = new($"{runAgentUri}?thread_id={threadId}");
|
||||
await this.WaitForConditionAsync(
|
||||
condition: async () =>
|
||||
{
|
||||
this._outputHelper.WriteLine($"Checking status of orchestration at {statusUri}...");
|
||||
|
||||
using StringContent content = new("Get the status of the workflow", Encoding.UTF8, "text/plain");
|
||||
using HttpResponseMessage statusResponse = await s_sharedHttpClient.PostAsync(statusUri, content);
|
||||
Assert.True(
|
||||
statusResponse.IsSuccessStatusCode,
|
||||
$"Status check failed with status: {statusResponse.StatusCode}");
|
||||
string statusText = await statusResponse.Content.ReadAsStringAsync();
|
||||
this._outputHelper.WriteLine($"Status text: {statusText}");
|
||||
|
||||
bool isCompleted = statusText.Contains("Completed", StringComparison.OrdinalIgnoreCase);
|
||||
bool hasContent = statusText.Contains(
|
||||
"The Future of Artificial Intelligence",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
return isCompleted && hasContent;
|
||||
},
|
||||
message: "Orchestration is completed",
|
||||
timeout: TimeSpan.FromSeconds(60));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentAsMcpToolAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "07_AgentAsMcpTool");
|
||||
await this.RunSampleTestAsync(samplePath, async (logs) =>
|
||||
{
|
||||
IClientTransport clientTransport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new Uri($"http://localhost:{AzureFunctionsPort}/runtime/webhooks/mcp")
|
||||
});
|
||||
|
||||
await using McpClient mcpClient = await McpClient.CreateAsync(clientTransport!);
|
||||
|
||||
// Ensure the expected tools are present.
|
||||
IList<McpClientTool> tools = await mcpClient.ListToolsAsync();
|
||||
|
||||
Assert.Single(tools, t => t.Name == "StockAdvisor");
|
||||
Assert.Single(tools, t => t.Name == "PlantAdvisor");
|
||||
|
||||
// Invoke the tools to verify they work as expected.
|
||||
string stockPriceResponse = await this.InvokeMcpToolAsync(mcpClient, "StockAdvisor", "MSFT ATH");
|
||||
string plantSuggestionResponse = await this.InvokeMcpToolAsync(mcpClient, "PlantAdvisor", "Low light plant");
|
||||
Assert.NotEmpty(stockPriceResponse);
|
||||
Assert.NotEmpty(plantSuggestionResponse);
|
||||
|
||||
// Wait for up to 30 seconds to see if the agent responses are available in the logs
|
||||
await this.WaitForConditionAsync(
|
||||
condition: () =>
|
||||
{
|
||||
lock (logs)
|
||||
{
|
||||
bool expectedLogsPresent = logs.Count(log => log.Message.Contains("Response:")) >= 2;
|
||||
return Task.FromResult(expectedLogsPresent);
|
||||
}
|
||||
},
|
||||
message: "Agent response is available",
|
||||
timeout: TimeSpan.FromSeconds(30));
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<string> InvokeMcpToolAsync(McpClient mcpClient, string toolName, string query)
|
||||
{
|
||||
this._outputHelper.WriteLine($"Invoking MCP tool '{toolName}'...");
|
||||
|
||||
CallToolResult result = await mcpClient.CallToolAsync(
|
||||
toolName,
|
||||
arguments: new Dictionary<string, object?> { { "query", query } });
|
||||
|
||||
string toolCallResult = ((TextContentBlock)result.Content[0]).Text;
|
||||
this._outputHelper.WriteLine($"MCP tool '{toolName}' response: {toolCallResult}");
|
||||
|
||||
return toolCallResult;
|
||||
}
|
||||
|
||||
private async Task TestSpamDetectionAsync(string emailId, string emailContent, bool expectedSpam)
|
||||
{
|
||||
object requestBody = new
|
||||
{
|
||||
email_id = emailId,
|
||||
email_content = emailContent
|
||||
};
|
||||
|
||||
string jsonContent = JsonSerializer.Serialize(requestBody);
|
||||
using HttpContent content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
|
||||
|
||||
Uri startUri = new($"http://localhost:{AzureFunctionsPort}/api/spamdetection/run");
|
||||
this._outputHelper.WriteLine($"Starting spam detection orchestration via POST request to {startUri}...");
|
||||
using HttpResponseMessage startResponse = await s_sharedHttpClient.PostAsync(startUri, content);
|
||||
|
||||
Assert.True(startResponse.IsSuccessStatusCode, $"Start orchestration failed with status: {startResponse.StatusCode}");
|
||||
string startResponseText = await startResponse.Content.ReadAsStringAsync();
|
||||
JsonElement startResult = JsonSerializer.Deserialize<JsonElement>(startResponseText);
|
||||
|
||||
Assert.True(startResult.TryGetProperty("statusQueryGetUri", out JsonElement statusUriElement));
|
||||
Uri statusUri = new(statusUriElement.GetString()!);
|
||||
|
||||
// Wait for orchestration to complete
|
||||
await this.WaitForOrchestrationCompletionAsync(statusUri);
|
||||
|
||||
// Verify the final result
|
||||
using HttpResponseMessage statusResponse = await s_sharedHttpClient.GetAsync(statusUri);
|
||||
Assert.True(statusResponse.IsSuccessStatusCode, $"Status check failed with status: {statusResponse.StatusCode}");
|
||||
|
||||
string statusText = await statusResponse.Content.ReadAsStringAsync();
|
||||
JsonElement statusResult = JsonSerializer.Deserialize<JsonElement>(statusText);
|
||||
|
||||
Assert.Equal("Completed", statusResult.GetProperty("runtimeStatus").GetString());
|
||||
Assert.True(statusResult.TryGetProperty("output", out JsonElement outputElement));
|
||||
|
||||
string output = outputElement.GetString()!;
|
||||
Assert.NotEmpty(output);
|
||||
|
||||
if (expectedSpam)
|
||||
{
|
||||
Assert.Contains("spam", output, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Contains("sent", output, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartSharedInfrastructureAsync()
|
||||
{
|
||||
// Start Azurite if it's not already running
|
||||
if (!await this.IsAzuriteRunningAsync())
|
||||
{
|
||||
await this.StartDockerContainerAsync(
|
||||
containerName: "azurite",
|
||||
image: "mcr.microsoft.com/azure-storage/azurite",
|
||||
ports: ["-p", "10000:10000", "-p", "10001:10001", "-p", "10002:10002"]);
|
||||
|
||||
// Wait for Azurite
|
||||
await this.WaitForConditionAsync(this.IsAzuriteRunningAsync, "Azurite is running", TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
// Start DTS emulator if it's not already running
|
||||
if (!await this.IsDtsEmulatorRunningAsync())
|
||||
{
|
||||
await this.StartDockerContainerAsync(
|
||||
containerName: "dts-emulator",
|
||||
image: "mcr.microsoft.com/dts/dts-emulator:latest",
|
||||
ports: ["-p", "8080:8080", "-p", "8082:8082"]);
|
||||
|
||||
// Wait for DTS emulator
|
||||
await this.WaitForConditionAsync(
|
||||
condition: this.IsDtsEmulatorRunningAsync,
|
||||
message: "DTS emulator is running",
|
||||
timeout: TimeSpan.FromSeconds(30));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsAzuriteRunningAsync()
|
||||
{
|
||||
this._outputHelper.WriteLine(
|
||||
$"Checking if Azurite is running at http://localhost:{AzuritePort}/devstoreaccount1...");
|
||||
|
||||
try
|
||||
{
|
||||
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Example output when pinging Azurite:
|
||||
// $ curl -i http://localhost:10000/devstoreaccount1?comp=list
|
||||
// HTTP/1.1 403 Server failed to authenticate the request.
|
||||
// Server: Azurite-Blob/3.34.0
|
||||
// x-ms-error-code: AuthorizationFailure
|
||||
// x-ms-request-id: 6cd21522-bb0f-40f6-962c-fa174f17aa30
|
||||
// content-type: application/xml
|
||||
// Date: Mon, 20 Oct 2025 23:52:02 GMT
|
||||
// Connection: keep-alive
|
||||
// Keep-Alive: timeout=5
|
||||
// Transfer-Encoding: chunked
|
||||
using HttpResponseMessage response = await s_sharedHttpClient.GetAsync(
|
||||
requestUri: new Uri($"http://localhost:{AzuritePort}/devstoreaccount1?comp=list"),
|
||||
cancellationToken: timeoutCts.Token);
|
||||
if (response.Headers.TryGetValues(
|
||||
"Server",
|
||||
out IEnumerable<string>? serverValues) && serverValues.Any(s => s.StartsWith("Azurite", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
this._outputHelper.WriteLine($"Azurite is running, server: {string.Join(", ", serverValues)}");
|
||||
return true;
|
||||
}
|
||||
|
||||
this._outputHelper.WriteLine($"Azurite is not running. Status code: {response.StatusCode}");
|
||||
return false;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
this._outputHelper.WriteLine($"Azurite is not running: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsDtsEmulatorRunningAsync()
|
||||
{
|
||||
this._outputHelper.WriteLine($"Checking if DTS emulator is running at http://localhost:{DtsPort}/healthz...");
|
||||
|
||||
// DTS emulator doesn't support HTTP/1.1, so we need to use HTTP/2.0
|
||||
using HttpClient http2Client = new()
|
||||
{
|
||||
DefaultRequestVersion = new Version(2, 0),
|
||||
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
|
||||
using HttpResponseMessage response = await http2Client.GetAsync(new Uri($"http://localhost:{DtsPort}/healthz"), timeoutCts.Token);
|
||||
if (response.Content.Headers.ContentLength > 0)
|
||||
{
|
||||
string content = await response.Content.ReadAsStringAsync(timeoutCts.Token);
|
||||
this._outputHelper.WriteLine($"DTS emulator health check response: {content}");
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
this._outputHelper.WriteLine("DTS emulator is running");
|
||||
return true;
|
||||
}
|
||||
|
||||
this._outputHelper.WriteLine($"DTS emulator is not running. Status code: {response.StatusCode}");
|
||||
return false;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
this._outputHelper.WriteLine($"DTS emulator is not running: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartDockerContainerAsync(string containerName, string image, string[] ports)
|
||||
{
|
||||
// Stop existing container if it exists
|
||||
await this.RunCommandAsync("docker", ["stop", containerName]);
|
||||
await this.RunCommandAsync("docker", ["rm", containerName]);
|
||||
|
||||
// Start new container
|
||||
List<string> args = ["run", "-d", "--name", containerName];
|
||||
args.AddRange(ports);
|
||||
args.Add(image);
|
||||
|
||||
this._outputHelper.WriteLine(
|
||||
$"Starting new container: {containerName} with image: {image} and ports: {string.Join(", ", ports)}");
|
||||
await this.RunCommandAsync("docker", args.ToArray());
|
||||
this._outputHelper.WriteLine($"Container started: {containerName}");
|
||||
}
|
||||
|
||||
private async Task WaitForConditionAsync(Func<Task<bool>> condition, string message, TimeSpan timeout)
|
||||
{
|
||||
this._outputHelper.WriteLine($"Waiting for '{message}'...");
|
||||
|
||||
using CancellationTokenSource cancellationTokenSource = new(timeout);
|
||||
while (true)
|
||||
{
|
||||
if (await condition())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationTokenSource.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationTokenSource.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException($"Timeout waiting for '{message}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunSampleTestAsync(string samplePath, Func<IReadOnlyList<OutputLog>, Task> testAction)
|
||||
{
|
||||
// Start the Azure Functions app
|
||||
List<OutputLog> logsContainer = [];
|
||||
using Process funcProcess = this.StartFunctionApp(samplePath, logsContainer);
|
||||
try
|
||||
{
|
||||
// Wait for the app to be ready
|
||||
await this.WaitForAzureFunctionsAsync();
|
||||
|
||||
// Run the test
|
||||
await testAction(logsContainer);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this.StopProcessAsync(funcProcess);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message);
|
||||
|
||||
private Process StartFunctionApp(string samplePath, List<OutputLog> logs)
|
||||
{
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = $"run -f {s_dotnetTargetFramework} --port {AzureFunctionsPort}",
|
||||
WorkingDirectory = samplePath,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
|
||||
string openAiEndpoint = s_configuration["AZURE_OPENAI_ENDPOINT"] ??
|
||||
throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set.");
|
||||
string openAiDeployment = s_configuration["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] ??
|
||||
throw new InvalidOperationException("The required AZURE_OPENAI_CHAT_DEPLOYMENT_NAME env variable is not set.");
|
||||
|
||||
// Set required environment variables for the function app (see local.settings.json for required settings)
|
||||
startInfo.EnvironmentVariables["AZURE_OPENAI_ENDPOINT"] = openAiEndpoint;
|
||||
startInfo.EnvironmentVariables["AZURE_OPENAI_DEPLOYMENT"] = openAiDeployment;
|
||||
startInfo.EnvironmentVariables["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"] =
|
||||
$"Endpoint=http://localhost:{DtsPort};TaskHub=default;Authentication=None";
|
||||
startInfo.EnvironmentVariables["AzureWebJobsStorage"] = "UseDevelopmentStorage=true";
|
||||
|
||||
Process process = new() { StartInfo = startInfo };
|
||||
|
||||
// Capture the output and error streams
|
||||
process.ErrorDataReceived += (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
this._outputHelper.WriteLine($"[{startInfo.FileName}(err)]: {e.Data}");
|
||||
lock (logs)
|
||||
{
|
||||
logs.Add(new OutputLog(DateTime.Now, LogLevel.Error, e.Data));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
process.OutputDataReceived += (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
this._outputHelper.WriteLine($"[{startInfo.FileName}(out)]: {e.Data}");
|
||||
lock (logs)
|
||||
{
|
||||
logs.Add(new OutputLog(DateTime.Now, LogLevel.Information, e.Data));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start the function app");
|
||||
}
|
||||
|
||||
process.BeginErrorReadLine();
|
||||
process.BeginOutputReadLine();
|
||||
|
||||
return process;
|
||||
}
|
||||
|
||||
private async Task WaitForAzureFunctionsAsync()
|
||||
{
|
||||
this._outputHelper.WriteLine(
|
||||
$"Waiting for Azure Functions Core Tools to be ready at http://localhost:{AzureFunctionsPort}/...");
|
||||
await this.WaitForConditionAsync(
|
||||
condition: async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using HttpRequestMessage request = new(HttpMethod.Head, $"http://localhost:{AzureFunctionsPort}/");
|
||||
using HttpResponseMessage response = await s_sharedHttpClient.SendAsync(request);
|
||||
this._outputHelper.WriteLine($"Azure Functions Core Tools response: {response.StatusCode}");
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
// Expected when the app isn't yet ready
|
||||
return false;
|
||||
}
|
||||
},
|
||||
message: "Azure Functions Core Tools is ready",
|
||||
timeout: TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
private async Task WaitForOrchestrationCompletionAsync(Uri statusUri)
|
||||
{
|
||||
using CancellationTokenSource timeoutCts = new(s_orchestrationTimeout);
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await s_sharedHttpClient.GetAsync(
|
||||
statusUri,
|
||||
timeoutCts.Token);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
string responseText = await response.Content.ReadAsStringAsync(timeoutCts.Token);
|
||||
JsonElement result = JsonSerializer.Deserialize<JsonElement>(responseText);
|
||||
|
||||
if (result.TryGetProperty("runtimeStatus", out JsonElement statusElement))
|
||||
{
|
||||
string status = statusElement.GetString()!;
|
||||
if (status == "Completed" || status == "Failed" || status == "Terminated")
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!timeoutCts.Token.IsCancellationRequested)
|
||||
{
|
||||
// Ignore errors and retry
|
||||
this._outputHelper.WriteLine($"Error waiting for orchestration completion: {ex}");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), timeoutCts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunCommandAsync(string command, string[] args)
|
||||
{
|
||||
await this.RunCommandAsync(command, workingDirectory: null, args: args);
|
||||
}
|
||||
|
||||
private async Task RunCommandAsync(string command, string? workingDirectory, string[] args)
|
||||
{
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
FileName = command,
|
||||
Arguments = string.Join(" ", args),
|
||||
WorkingDirectory = workingDirectory,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
this._outputHelper.WriteLine($"Running command: {command} {string.Join(" ", args)}");
|
||||
|
||||
using Process process = new() { StartInfo = startInfo };
|
||||
process.ErrorDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(err)]: {e.Data}");
|
||||
process.OutputDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(out)]: {e.Data}");
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start the command");
|
||||
}
|
||||
process.BeginErrorReadLine();
|
||||
process.BeginOutputReadLine();
|
||||
|
||||
using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(1));
|
||||
await process.WaitForExitAsync(cancellationTokenSource.Token);
|
||||
|
||||
this._outputHelper.WriteLine($"Command completed with exit code: {process.ExitCode}");
|
||||
}
|
||||
|
||||
private async Task StopProcessAsync(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
this._outputHelper.WriteLine($"Killing process {process.ProcessName}#{process.Id}");
|
||||
process.Kill(entireProcessTree: true);
|
||||
|
||||
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(10));
|
||||
await process.WaitForExitAsync(timeoutCts.Token);
|
||||
this._outputHelper.WriteLine($"Process exited: {process.Id}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._outputHelper.WriteLine($"Failed to stop process: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetTargetFramework()
|
||||
{
|
||||
// Get the target framework by looking at the path of the current file. It should be something like /path/to/project/bin/Debug/net8.0/...
|
||||
string filePath = new Uri(typeof(SamplesValidation).Assembly.Location).LocalPath;
|
||||
string directory = Path.GetDirectoryName(filePath)!;
|
||||
string tfm = Path.GetFileName(directory);
|
||||
if (tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return tfm;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unable to find target framework in path: {filePath}");
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests;
|
||||
|
||||
public sealed class DurableAgentFunctionMetadataTransformerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, false, false, 1)] // entity only
|
||||
[InlineData(0, true, false, 2)] // entity + http
|
||||
[InlineData(0, false, true, 2)] // entity + mcp tool
|
||||
[InlineData(0, true, true, 3)] // entity + http + mcp tool
|
||||
[InlineData(3, true, true, 3)] // entity + http + mcp tool added to existing
|
||||
public void Transform_AddsAgentAndHttpTriggers_ForEachAgent(
|
||||
int initialMetadataEntryCount,
|
||||
bool enableHttp,
|
||||
bool enableMcp,
|
||||
int expectedMetadataCount)
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, Func<IServiceProvider, AIAgent>> agents = new()
|
||||
{
|
||||
{ "testAgent", _ => new TestAgent("testAgent", "Test agent description") }
|
||||
};
|
||||
|
||||
FunctionsAgentOptions options = new();
|
||||
|
||||
options.HttpTrigger.IsEnabled = enableHttp;
|
||||
options.McpToolTrigger.IsEnabled = enableMcp;
|
||||
|
||||
IFunctionsAgentOptionsProvider agentOptionsProvider = new FakeOptionsProvider(new Dictionary<string, FunctionsAgentOptions>
|
||||
{
|
||||
{ "testAgent", options }
|
||||
});
|
||||
|
||||
List<IFunctionMetadata> metadataList = BuildFunctionMetadataList(initialMetadataEntryCount);
|
||||
|
||||
DurableAgentFunctionMetadataTransformer transformer = new(
|
||||
agents,
|
||||
NullLogger<DurableAgentFunctionMetadataTransformer>.Instance,
|
||||
new FakeServiceProvider(),
|
||||
agentOptionsProvider);
|
||||
|
||||
// Act
|
||||
transformer.Transform(metadataList);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(initialMetadataEntryCount + expectedMetadataCount, metadataList.Count);
|
||||
|
||||
DefaultFunctionMetadata agentTrigger = Assert.IsType<DefaultFunctionMetadata>(metadataList[initialMetadataEntryCount]);
|
||||
Assert.Equal("dafx-testAgent", agentTrigger.Name);
|
||||
Assert.Contains("entityTrigger", agentTrigger.RawBindings![0]);
|
||||
|
||||
if (enableHttp)
|
||||
{
|
||||
DefaultFunctionMetadata httpTrigger = Assert.IsType<DefaultFunctionMetadata>(metadataList[initialMetadataEntryCount + 1]);
|
||||
Assert.Equal("http-testAgent", httpTrigger.Name);
|
||||
Assert.Contains("httpTrigger", httpTrigger.RawBindings![0]);
|
||||
}
|
||||
|
||||
if (enableMcp)
|
||||
{
|
||||
int mcpIndex = initialMetadataEntryCount + (enableHttp ? 2 : 1);
|
||||
DefaultFunctionMetadata mcpToolTrigger = Assert.IsType<DefaultFunctionMetadata>(metadataList[mcpIndex]);
|
||||
Assert.Equal("mcptool-testAgent", mcpToolTrigger.Name);
|
||||
Assert.Contains("mcpToolTrigger", mcpToolTrigger.RawBindings![0]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transform_AddsTriggers_ForMultipleAgents()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, Func<IServiceProvider, AIAgent>> agents = new()
|
||||
{
|
||||
{ "agentA", _ => new TestAgent("testAgentA", "Test agent description") },
|
||||
{ "agentB", _ => new TestAgent("testAgentB", "Test agent description") },
|
||||
{ "agentC", _ => new TestAgent("testAgentC", "Test agent description") }
|
||||
};
|
||||
|
||||
// Helper to create options with configurable triggers
|
||||
static FunctionsAgentOptions CreateFunctionsAgentOptions(bool httpEnabled, bool mcpEnabled)
|
||||
{
|
||||
FunctionsAgentOptions options = new();
|
||||
options.HttpTrigger.IsEnabled = httpEnabled;
|
||||
options.McpToolTrigger.IsEnabled = mcpEnabled;
|
||||
return options;
|
||||
}
|
||||
|
||||
FunctionsAgentOptions agentOptionsA = CreateFunctionsAgentOptions(true, false);
|
||||
FunctionsAgentOptions agentOptionsB = CreateFunctionsAgentOptions(true, true);
|
||||
FunctionsAgentOptions agentOptionsC = CreateFunctionsAgentOptions(true, true);
|
||||
|
||||
Dictionary<string, FunctionsAgentOptions> functionsAgentOptions = new()
|
||||
{
|
||||
{ "agentA", agentOptionsA },
|
||||
{ "agentB", agentOptionsB },
|
||||
{ "agentC", agentOptionsC }
|
||||
};
|
||||
|
||||
IFunctionsAgentOptionsProvider agentOptionsProvider = new FakeOptionsProvider(functionsAgentOptions);
|
||||
DurableAgentFunctionMetadataTransformer transformer = new(
|
||||
agents,
|
||||
NullLogger<DurableAgentFunctionMetadataTransformer>.Instance,
|
||||
new FakeServiceProvider(),
|
||||
agentOptionsProvider);
|
||||
|
||||
const int InitialMetadataEntryCount = 2;
|
||||
List<IFunctionMetadata> metadataList = BuildFunctionMetadataList(InitialMetadataEntryCount);
|
||||
|
||||
// Act
|
||||
transformer.Transform(metadataList);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(InitialMetadataEntryCount + (agents.Count * 2) + 2, metadataList.Count);
|
||||
|
||||
foreach (string agentName in agents.Keys)
|
||||
{
|
||||
// The agent's entity trigger name is prefixed with "dafx-"
|
||||
DefaultFunctionMetadata entityMeta =
|
||||
Assert.IsType<DefaultFunctionMetadata>(
|
||||
Assert.Single(metadataList, m => m.Name == $"dafx-{agentName}"));
|
||||
Assert.NotNull(entityMeta.RawBindings);
|
||||
Assert.Contains("entityTrigger", entityMeta.RawBindings[0]);
|
||||
|
||||
DefaultFunctionMetadata httpMeta =
|
||||
Assert.IsType<DefaultFunctionMetadata>(
|
||||
Assert.Single(metadataList, m => m.Name == $"http-{agentName}"));
|
||||
Assert.NotNull(httpMeta.RawBindings);
|
||||
Assert.Contains("httpTrigger", httpMeta.RawBindings[0]);
|
||||
Assert.Contains($"agents/{agentName}/run", httpMeta.RawBindings[0]);
|
||||
|
||||
// We expect 2 mcp tool triggers only for agentB and agentC
|
||||
if (agentName == "agentB" || agentName == "agentC")
|
||||
{
|
||||
DefaultFunctionMetadata? mcpToolMeta =
|
||||
Assert.Single(metadataList, m => m.Name == $"mcptool-{agentName}") as DefaultFunctionMetadata;
|
||||
Assert.NotNull(mcpToolMeta);
|
||||
Assert.NotNull(mcpToolMeta.RawBindings);
|
||||
Assert.Equal(4, mcpToolMeta.RawBindings.Count);
|
||||
Assert.Contains("mcpToolTrigger", mcpToolMeta.RawBindings[0]);
|
||||
Assert.Contains("mcpToolProperty", mcpToolMeta.RawBindings[1]); // We expect 2 tool property bindings
|
||||
Assert.Contains("mcpToolProperty", mcpToolMeta.RawBindings[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<IFunctionMetadata> BuildFunctionMetadataList(int numberOfFunctions)
|
||||
{
|
||||
List<IFunctionMetadata> list = [];
|
||||
for (int i = 0; i < numberOfFunctions; i++)
|
||||
{
|
||||
list.Add(new DefaultFunctionMetadata
|
||||
{
|
||||
Language = "dotnet-isolated",
|
||||
Name = $"SingleAgentOrchestration{i + 1}",
|
||||
EntryPoint = "MyApp.Functions.SingleAgentOrchestration",
|
||||
RawBindings = ["{\r\n \"name\": \"context\",\r\n \"direction\": \"In\",\r\n \"type\": \"orchestrationTrigger\",\r\n \"properties\": {}\r\n }"],
|
||||
ScriptFile = "MyApp.dll"
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private sealed class FakeServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
|
||||
private sealed class FakeOptionsProvider : IFunctionsAgentOptionsProvider
|
||||
{
|
||||
private readonly Dictionary<string, FunctionsAgentOptions> _map;
|
||||
|
||||
public FakeOptionsProvider(Dictionary<string, FunctionsAgentOptions> map)
|
||||
{
|
||||
this._map = map ?? throw new ArgumentNullException(nameof(map));
|
||||
}
|
||||
|
||||
public bool TryGet(string agentName, [NotNullWhen(true)] out FunctionsAgentOptions? options)
|
||||
=> this._map.TryGetValue(agentName, out options);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests;
|
||||
|
||||
internal sealed class TestAgent(string name, string description) : AIAgent
|
||||
{
|
||||
public override string? Name => name;
|
||||
|
||||
public override string? Description => description;
|
||||
|
||||
public override AgentThread GetNewThread() => new DummyAgentThread();
|
||||
|
||||
public override AgentThread DeserializeThread(
|
||||
JsonElement serializedThread,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null) => new DummyAgentThread();
|
||||
|
||||
public override Task<AgentRunResponse> RunAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(new AgentRunResponse([.. messages]));
|
||||
|
||||
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
private sealed class DummyAgentThread : AgentThread;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Purview\Microsoft.Agents.AI.Purview.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,585 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
using Microsoft.Agents.AI.Purview.Models.Requests;
|
||||
using Microsoft.Agents.AI.Purview.Models.Responses;
|
||||
using Microsoft.Agents.AI.Purview.Serialization;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="PurviewClient"/> class.
|
||||
/// </summary>
|
||||
public sealed class PurviewClientTests : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly PurviewClientHttpMessageHandlerStub _handler;
|
||||
private readonly PurviewClient _client;
|
||||
private readonly PurviewSettings _settings;
|
||||
|
||||
public PurviewClientTests()
|
||||
{
|
||||
this._handler = new PurviewClientHttpMessageHandlerStub();
|
||||
this._httpClient = new HttpClient(this._handler, false);
|
||||
this._settings = new PurviewSettings("TestApp")
|
||||
{
|
||||
GraphBaseUri = new Uri("https://graph.microsoft.com/v1.0/")
|
||||
};
|
||||
var tokenCredential = new MockTokenCredential();
|
||||
this._client = new PurviewClient(tokenCredential, this._settings, this._httpClient, NullLogger.Instance);
|
||||
}
|
||||
|
||||
#region ProcessContentAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithValidRequest_ReturnsSuccessResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidProcessContentRequest();
|
||||
var expectedResponse = new ProcessContentResponse
|
||||
{
|
||||
Id = "test-id-123",
|
||||
ProtectionScopeState = ProtectionScopeState.NotModified,
|
||||
PolicyActions = new List<DlpActionInfo>
|
||||
{
|
||||
new() { Action = DlpAction.NotifyUser }
|
||||
}
|
||||
};
|
||||
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.OK;
|
||||
this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse)));
|
||||
|
||||
// Act
|
||||
var result = await this._client.ProcessContentAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(expectedResponse.Id, result.Id);
|
||||
Assert.Equal(ProtectionScopeState.NotModified, result.ProtectionScopeState);
|
||||
Assert.Single(result.PolicyActions!);
|
||||
Assert.Equal(DlpAction.NotifyUser, result.PolicyActions![0].Action);
|
||||
|
||||
// Verify request
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/users/test-user-id/dataSecurityAndGovernance/processContent", this._handler.RequestUri?.ToString());
|
||||
Assert.Equal(HttpMethod.Post, this._handler.RequestMethod);
|
||||
Assert.Contains("Bearer ", this._handler.AuthorizationHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithAcceptedStatus_ReturnsSuccessResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidProcessContentRequest();
|
||||
var expectedResponse = new ProcessContentResponse
|
||||
{
|
||||
Id = "test-id-456",
|
||||
ProtectionScopeState = ProtectionScopeState.Modified
|
||||
};
|
||||
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.Accepted;
|
||||
this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse)));
|
||||
|
||||
// Act
|
||||
var result = await this._client.ProcessContentAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(expectedResponse.Id, result.Id);
|
||||
Assert.Equal(ProtectionScopeState.Modified, result.ProtectionScopeState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithScopeIdentifier_IncludesIfNoneMatchHeaderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidProcessContentRequest();
|
||||
request.ScopeIdentifier = "\"test-scope-123\""; // ETags must be quoted
|
||||
var expectedResponse = new ProcessContentResponse { Id = "test-id" };
|
||||
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.OK;
|
||||
this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse)));
|
||||
|
||||
// Act
|
||||
await this._client.ProcessContentAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("\"test-scope-123\"", this._handler.IfNoneMatchHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidProcessContentRequest();
|
||||
this._handler.StatusCodeToReturn = (HttpStatusCode)429;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<PurviewRateLimitException>(() =>
|
||||
this._client.ProcessContentAsync(request, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidProcessContentRequest();
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<PurviewAuthenticationException>(() =>
|
||||
this._client.ProcessContentAsync(request, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithForbiddenError_ThrowsPurviewAuthenticationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidProcessContentRequest();
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.Forbidden;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<PurviewAuthenticationException>(() =>
|
||||
this._client.ProcessContentAsync(request, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithPaymentRequiredError_ThrowsPurviewPaymentRequiredExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidProcessContentRequest();
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.PaymentRequired;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<PurviewPaymentRequiredException>(() =>
|
||||
this._client.ProcessContentAsync(request, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithBadRequestError_ThrowsPurviewRequestExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidProcessContentRequest();
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.BadRequest;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<PurviewRequestException>(() =>
|
||||
this._client.ProcessContentAsync(request, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidProcessContentRequest();
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.OK;
|
||||
this._handler.ResponseToReturn = "invalid json";
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<PurviewRequestException>(() =>
|
||||
this._client.ProcessContentAsync(request, CancellationToken.None));
|
||||
|
||||
Assert.Contains("Failed to deserialize ProcessContent response", exception.Message);
|
||||
Assert.NotNull(exception.InnerException);
|
||||
Assert.IsType<JsonException>(exception.InnerException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidProcessContentRequest();
|
||||
this._handler.ShouldThrowHttpRequestException = true;
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<PurviewRequestException>(() =>
|
||||
this._client.ProcessContentAsync(request, CancellationToken.None));
|
||||
|
||||
Assert.Equal("Http error occurred while processing content.", exception.Message);
|
||||
Assert.NotNull(exception.InnerException);
|
||||
Assert.IsType<HttpRequestException>(exception.InnerException);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetProtectionScopesAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetProtectionScopesAsync_WithValidRequest_ReturnsSuccessResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id")
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations = new List<PolicyLocation>
|
||||
{
|
||||
new("microsoft.graph.policyLocationApplication", "app-123")
|
||||
}
|
||||
};
|
||||
|
||||
var expectedResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes = new List<PolicyScopeBase>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations = new List<PolicyLocation>
|
||||
{
|
||||
new ("microsoft.graph.policyLocationApplication", "app-123")
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.OK;
|
||||
this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesResponse)));
|
||||
this._handler.ETagToReturn = "\"scope-etag-123\"";
|
||||
|
||||
// Act
|
||||
var result = await this._client.GetProtectionScopesAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Scopes);
|
||||
Assert.Single(result.Scopes);
|
||||
Assert.Equal("\"scope-etag-123\"", result.ScopeIdentifier); // ETags are stored with quotes
|
||||
|
||||
// Verify request
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/users/test-user-id/dataSecurityAndGovernance/protectionScopes/compute", this._handler.RequestUri?.ToString());
|
||||
Assert.Equal(HttpMethod.Post, this._handler.RequestMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProtectionScopesAsync_SetsETagFromResponse_Async()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id");
|
||||
var expectedResponse = new ProtectionScopesResponse { Scopes = new List<PolicyScopeBase>() };
|
||||
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.OK;
|
||||
this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesResponse)));
|
||||
this._handler.ETagToReturn = "\"custom-etag-456\"";
|
||||
|
||||
// Act
|
||||
var result = await this._client.GetProtectionScopesAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("\"custom-etag-456\"", result.ScopeIdentifier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProtectionScopesAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id");
|
||||
this._handler.StatusCodeToReturn = (HttpStatusCode)429;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<PurviewRateLimitException>(() =>
|
||||
this._client.GetProtectionScopesAsync(request, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProtectionScopesAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id");
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<PurviewAuthenticationException>(() =>
|
||||
this._client.GetProtectionScopesAsync(request, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProtectionScopesAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id");
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.OK;
|
||||
this._handler.ResponseToReturn = "invalid json";
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<PurviewRequestException>(() =>
|
||||
this._client.GetProtectionScopesAsync(request, CancellationToken.None));
|
||||
|
||||
Assert.Contains("Failed to deserialize ProtectionScopes response", exception.Message);
|
||||
Assert.NotNull(exception.InnerException);
|
||||
Assert.IsType<JsonException>(exception.InnerException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProtectionScopesAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id");
|
||||
this._handler.ShouldThrowHttpRequestException = true;
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<PurviewRequestException>(() =>
|
||||
this._client.GetProtectionScopesAsync(request, CancellationToken.None));
|
||||
|
||||
Assert.Equal("Http error occurred while retrieving protection scopes.", exception.Message);
|
||||
Assert.NotNull(exception.InnerException);
|
||||
Assert.IsType<HttpRequestException>(exception.InnerException);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendContentActivitiesAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendContentActivitiesAsync_WithValidRequest_ReturnsSuccessResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var contentToProcess = CreateValidContentToProcess();
|
||||
var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess);
|
||||
var expectedResponse = new ContentActivitiesResponse
|
||||
{
|
||||
StatusCode = HttpStatusCode.Created
|
||||
};
|
||||
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.Created;
|
||||
this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesResponse)));
|
||||
|
||||
// Act
|
||||
var result = await this._client.SendContentActivitiesAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.Error);
|
||||
|
||||
// Verify request - note the endpoint is different from ProcessContent
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/test-user-id/dataSecurityAndGovernance/activities/contentActivities", this._handler.RequestUri?.ToString());
|
||||
Assert.Equal(HttpMethod.Post, this._handler.RequestMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendContentActivitiesAsync_WithError_ReturnsResponseWithErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var contentToProcess = CreateValidContentToProcess();
|
||||
var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess);
|
||||
var expectedResponse = new ContentActivitiesResponse
|
||||
{
|
||||
Error = new ErrorDetails
|
||||
{
|
||||
Code = "InvalidRequest",
|
||||
Message = "The request is invalid"
|
||||
}
|
||||
};
|
||||
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.Created;
|
||||
this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesResponse)));
|
||||
|
||||
// Act
|
||||
var result = await this._client.SendContentActivitiesAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Error);
|
||||
Assert.Equal("InvalidRequest", result.Error.Code);
|
||||
Assert.Equal("The request is invalid", result.Error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendContentActivitiesAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var contentToProcess = CreateValidContentToProcess();
|
||||
var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess);
|
||||
this._handler.StatusCodeToReturn = (HttpStatusCode)429;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<PurviewRateLimitException>(() =>
|
||||
this._client.SendContentActivitiesAsync(request, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendContentActivitiesAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var contentToProcess = CreateValidContentToProcess();
|
||||
var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess);
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<PurviewAuthenticationException>(() =>
|
||||
this._client.SendContentActivitiesAsync(request, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendContentActivitiesAsync_WithBadRequestError_ThrowsPurviewRequestExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var contentToProcess = CreateValidContentToProcess();
|
||||
var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess);
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.BadRequest;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<PurviewRequestException>(() =>
|
||||
this._client.SendContentActivitiesAsync(request, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendContentActivitiesAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var contentToProcess = CreateValidContentToProcess();
|
||||
var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess);
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.Created;
|
||||
this._handler.ResponseToReturn = "invalid json";
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<PurviewRequestException>(() =>
|
||||
this._client.SendContentActivitiesAsync(request, CancellationToken.None));
|
||||
|
||||
Assert.Contains("Failed to deserialize ContentActivities response", exception.Message);
|
||||
Assert.NotNull(exception.InnerException);
|
||||
Assert.IsType<JsonException>(exception.InnerException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendContentActivitiesAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var contentToProcess = CreateValidContentToProcess();
|
||||
var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess);
|
||||
this._handler.ShouldThrowHttpRequestException = true;
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<PurviewRequestException>(() =>
|
||||
this._client.SendContentActivitiesAsync(request, CancellationToken.None));
|
||||
|
||||
Assert.Equal("Http error occurred while creating content activities.", exception.Message);
|
||||
Assert.NotNull(exception.InnerException);
|
||||
Assert.IsType<HttpRequestException>(exception.InnerException);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static ProcessContentRequest CreateValidProcessContentRequest()
|
||||
{
|
||||
var contentToProcess = CreateValidContentToProcess();
|
||||
return new ProcessContentRequest(contentToProcess, "test-user-id", "test-tenant-id");
|
||||
}
|
||||
|
||||
private static ContentToProcess CreateValidContentToProcess()
|
||||
{
|
||||
var content = new PurviewTextContent("Test content");
|
||||
var metadata = new ProcessConversationMetadata(content, "msg-123", false, "Test message");
|
||||
var activityMetadata = new ActivityMetadata(Activity.UploadText);
|
||||
var deviceMetadata = new DeviceMetadata
|
||||
{
|
||||
OperatingSystemSpecifications = new OperatingSystemSpecifications
|
||||
{
|
||||
OperatingSystemPlatform = "Windows",
|
||||
OperatingSystemVersion = "10"
|
||||
}
|
||||
};
|
||||
var integratedAppMetadata = new IntegratedAppMetadata
|
||||
{
|
||||
Name = "TestApp",
|
||||
Version = "1.0"
|
||||
};
|
||||
var policyLocation = new PolicyLocation("microsoft.graph.policyLocationApplication", "app-123");
|
||||
var protectedAppMetadata = new ProtectedAppMetadata(policyLocation)
|
||||
{
|
||||
Name = "TestApp",
|
||||
Version = "1.0"
|
||||
};
|
||||
|
||||
return new ContentToProcess(
|
||||
new List<ProcessContentMetadataBase> { metadata },
|
||||
activityMetadata,
|
||||
deviceMetadata,
|
||||
integratedAppMetadata,
|
||||
protectedAppMetadata
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._handler.Dispose();
|
||||
this._httpClient.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock HTTP message handler for testing
|
||||
/// </summary>
|
||||
internal sealed class PurviewClientHttpMessageHandlerStub : HttpMessageHandler
|
||||
{
|
||||
public HttpStatusCode StatusCodeToReturn { get; set; } = HttpStatusCode.OK;
|
||||
public string? ResponseToReturn { get; set; }
|
||||
public string? ETagToReturn { get; set; }
|
||||
public bool ShouldThrowHttpRequestException { get; set; }
|
||||
public Uri? RequestUri { get; private set; }
|
||||
public HttpMethod? RequestMethod { get; private set; }
|
||||
public string? AuthorizationHeader { get; private set; }
|
||||
public string? IfNoneMatchHeader { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Capture request details
|
||||
this.RequestUri = request.RequestUri;
|
||||
this.RequestMethod = request.Method;
|
||||
|
||||
if (request.Headers.Authorization != null)
|
||||
{
|
||||
this.AuthorizationHeader = request.Headers.Authorization.ToString();
|
||||
}
|
||||
|
||||
if (request.Headers.TryGetValues("If-None-Match", out var ifNoneMatchValues))
|
||||
{
|
||||
this.IfNoneMatchHeader = string.Join(", ", ifNoneMatchValues);
|
||||
}
|
||||
|
||||
// Throw HttpRequestException if configured
|
||||
if (this.ShouldThrowHttpRequestException)
|
||||
{
|
||||
throw new HttpRequestException("Simulated network error");
|
||||
}
|
||||
|
||||
var response = new HttpResponseMessage(this.StatusCodeToReturn);
|
||||
|
||||
response.Content = new StringContent(this.ResponseToReturn ?? string.Empty, Encoding.UTF8, "application/json");
|
||||
|
||||
if (!string.IsNullOrEmpty(this.ETagToReturn))
|
||||
{
|
||||
response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue(this.ETagToReturn);
|
||||
}
|
||||
|
||||
return await Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock token credential for testing
|
||||
/// </summary>
|
||||
internal sealed class MockTokenCredential : TokenCredential
|
||||
{
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<AccessToken>(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="PurviewWrapper"/> class.
|
||||
/// </summary>
|
||||
public sealed class PurviewWrapperTests : IDisposable
|
||||
{
|
||||
private readonly Mock<IScopedContentProcessor> _mockProcessor;
|
||||
private readonly IChannelHandler _channelHandler;
|
||||
private readonly PurviewSettings _settings;
|
||||
private readonly PurviewWrapper _wrapper;
|
||||
|
||||
public PurviewWrapperTests()
|
||||
{
|
||||
this._mockProcessor = new Mock<IScopedContentProcessor>();
|
||||
this._channelHandler = Mock.Of<IChannelHandler>();
|
||||
this._settings = new PurviewSettings("TestApp")
|
||||
{
|
||||
TenantId = "tenant-123",
|
||||
PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123"),
|
||||
BlockedPromptMessage = "Prompt blocked by policy",
|
||||
BlockedResponseMessage = "Response blocked by policy"
|
||||
};
|
||||
this._wrapper = new PurviewWrapper(this._mockProcessor.Object, this._settings, NullLogger.Instance, this._channelHandler);
|
||||
}
|
||||
|
||||
#region ProcessChatContentAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessChatContentAsync_WithBlockedPrompt_ReturnsBlockedMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Sensitive content that should be blocked")
|
||||
};
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
|
||||
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Activity>(),
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((true, "user-123"));
|
||||
|
||||
// Act
|
||||
var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Messages);
|
||||
Assert.Equal(ChatRole.System, result.Messages[0].Role);
|
||||
Assert.Equal("Prompt blocked by policy", result.Messages[0].Text);
|
||||
mockChatClient.Verify(x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessChatContentAsync_WithAllowedPromptAndBlockedResponse_ReturnsBlockedMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response"));
|
||||
|
||||
mockChatClient.Setup(x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(innerResponse);
|
||||
|
||||
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Activity>(),
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((false, "user-123")) // Prompt allowed
|
||||
.ReturnsAsync((true, "user-123")); // Response blocked
|
||||
|
||||
// Act
|
||||
var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Messages);
|
||||
Assert.Equal(ChatRole.System, result.Messages[0].Role);
|
||||
Assert.Equal("Response blocked by policy", result.Messages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessChatContentAsync_WithAllowedPromptAndResponse_ReturnsInnerResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Safe response"));
|
||||
|
||||
mockChatClient.Setup(x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(innerResponse);
|
||||
|
||||
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Activity>(),
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((false, "user-123"));
|
||||
|
||||
// Act
|
||||
var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Same(innerResponse, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessChatContentAsync_WithIgnoreExceptions_ContinuesOnPromptErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var settingsWithIgnore = new PurviewSettings("TestApp")
|
||||
{
|
||||
TenantId = "tenant-123",
|
||||
IgnoreExceptions = true,
|
||||
PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123")
|
||||
};
|
||||
var wrapper = new PurviewWrapper(this._mockProcessor.Object, settingsWithIgnore, NullLogger.Instance, this._channelHandler);
|
||||
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
|
||||
var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response from inner client"));
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
mockChatClient.Setup(x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedResponse);
|
||||
|
||||
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Activity>(),
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new PurviewRequestException("Prompt processing error")); // Response processing succeeds
|
||||
|
||||
// Act
|
||||
var result = await wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Same(expectedResponse, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessChatContentAsync_WithoutIgnoreExceptions_ThrowsOnPromptErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
|
||||
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Activity>(),
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new PurviewRequestException("Prompt processing error"));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<PurviewRequestException>(() =>
|
||||
this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessChatContentAsync_UsesConversationIdFromOptions_Async()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
var options = new ChatOptions { ConversationId = "conversation-123" };
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response"));
|
||||
|
||||
mockChatClient.Setup(x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(innerResponse);
|
||||
|
||||
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
"conversation-123",
|
||||
It.IsAny<Activity>(),
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((false, "user-123"));
|
||||
|
||||
// Act
|
||||
await this._wrapper.ProcessChatContentAsync(messages, options, mockChatClient.Object, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
this._mockProcessor.Verify(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
"conversation-123",
|
||||
It.IsAny<Activity>(),
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ProcessAgentContentAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAgentContentAsync_WithBlockedPrompt_ReturnsBlockedMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Sensitive content")
|
||||
};
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
|
||||
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Activity>(),
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((true, "user-123"));
|
||||
|
||||
// Act
|
||||
var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Messages);
|
||||
Assert.Equal(ChatRole.System, result.Messages[0].Role);
|
||||
Assert.Equal("Prompt blocked by policy", result.Messages[0].Text);
|
||||
mockAgent.Verify(x => x.RunAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<AgentThread>(),
|
||||
It.IsAny<AgentRunOptions>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAgentContentAsync_WithAllowedPromptAndBlockedResponse_ReturnsBlockedMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response"));
|
||||
|
||||
mockAgent.Setup(x => x.RunAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<AgentThread>(),
|
||||
It.IsAny<AgentRunOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(innerResponse);
|
||||
|
||||
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Activity>(),
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((false, "user-123")) // Prompt allowed
|
||||
.ReturnsAsync((true, "user-123")); // Response blocked
|
||||
|
||||
// Act
|
||||
var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Messages);
|
||||
Assert.Equal(ChatRole.System, result.Messages[0].Role);
|
||||
Assert.Equal("Response blocked by policy", result.Messages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAgentContentAsync_WithAllowedPromptAndResponse_ReturnsInnerResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Safe response"));
|
||||
|
||||
mockAgent.Setup(x => x.RunAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<AgentThread>(),
|
||||
It.IsAny<AgentRunOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(innerResponse);
|
||||
|
||||
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Activity>(),
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((false, "user-123"));
|
||||
|
||||
// Act
|
||||
var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Same(innerResponse, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAgentContentAsync_WithIgnoreExceptions_ContinuesOnErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var settingsWithIgnore = new PurviewSettings("TestApp")
|
||||
{
|
||||
TenantId = "tenant-123",
|
||||
IgnoreExceptions = true,
|
||||
PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123")
|
||||
};
|
||||
var wrapper = new PurviewWrapper(this._mockProcessor.Object, settingsWithIgnore, NullLogger.Instance, this._channelHandler);
|
||||
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
|
||||
var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response from inner agent"));
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.Setup(x => x.RunAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<AgentThread>(),
|
||||
It.IsAny<AgentRunOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedResponse);
|
||||
|
||||
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Activity>(),
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new PurviewRequestException("Prompt processing error"))
|
||||
.ReturnsAsync((false, "user-123")); // Response processing succeeds
|
||||
|
||||
// Act
|
||||
var result = await wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Same(expectedResponse, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAgentContentAsync_WithoutIgnoreExceptions_ThrowsOnErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
|
||||
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Activity>(),
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new PurviewRequestException("Processing error"));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<PurviewRequestException>(() =>
|
||||
this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAgentContentAsync_ExtractsThreadIdFromMessageAdditionalProperties_Async()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
{
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
{ "conversationId", "conversation-from-props" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response"));
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.Setup(x => x.RunAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<AgentThread>(),
|
||||
It.IsAny<AgentRunOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedResponse);
|
||||
|
||||
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
"conversation-from-props",
|
||||
It.IsAny<Activity>(),
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((false, "user-123"));
|
||||
|
||||
// Act
|
||||
var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
this._mockProcessor.Verify(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
"conversation-from-props",
|
||||
It.IsAny<Activity>(),
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAgentContentAsync_GeneratesThreadId_WhenNotProvidedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
|
||||
var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response"));
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.Setup(x => x.RunAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<AgentThread>(),
|
||||
It.IsAny<AgentRunOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedResponse);
|
||||
|
||||
string? capturedThreadId = null;
|
||||
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Activity>(),
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, string, Activity, PurviewSettings, string, CancellationToken>(
|
||||
(_, threadId, _, _, _, _) => capturedThreadId = threadId)
|
||||
.ReturnsAsync((false, "user-123"));
|
||||
|
||||
// Act
|
||||
var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(capturedThreadId);
|
||||
Assert.True(Guid.TryParse(capturedThreadId, out _), "Generated thread ID should be a valid GUID");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAgentContentAsync_PassesResolvedUserId_ToResponseProcessingAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response"));
|
||||
|
||||
mockAgent.Setup(x => x.RunAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<AgentThread>(),
|
||||
It.IsAny<AgentRunOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(innerResponse);
|
||||
|
||||
var callCount = 0;
|
||||
string? firstCallUserId = null;
|
||||
string? secondCallUserId = null;
|
||||
|
||||
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Activity>(),
|
||||
It.IsAny<PurviewSettings>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, string, Activity, PurviewSettings, string, CancellationToken>(
|
||||
(_, _, _, _, userId, _) =>
|
||||
{
|
||||
if (callCount == 0)
|
||||
{
|
||||
firstCallUserId = userId;
|
||||
}
|
||||
else if (callCount == 1)
|
||||
{
|
||||
secondCallUserId = userId;
|
||||
}
|
||||
callCount++;
|
||||
})
|
||||
.ReturnsAsync((false, "resolved-user-456"));
|
||||
|
||||
// Act
|
||||
await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(firstCallUserId); // First call (prompt) should have null userId
|
||||
Assert.Equal("resolved-user-456", secondCallUserId); // Second call (response) should have resolved userId from first call
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._wrapper.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
using Microsoft.Agents.AI.Purview.Models.Jobs;
|
||||
using Microsoft.Agents.AI.Purview.Models.Requests;
|
||||
using Microsoft.Agents.AI.Purview.Models.Responses;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="ScopedContentProcessor"/> class.
|
||||
/// </summary>
|
||||
public sealed class ScopedContentProcessorTests
|
||||
{
|
||||
private readonly Mock<IPurviewClient> _mockPurviewClient;
|
||||
private readonly Mock<ICacheProvider> _mockCacheProvider;
|
||||
private readonly Mock<IChannelHandler> _mockChannelHandler;
|
||||
private readonly ScopedContentProcessor _processor;
|
||||
|
||||
public ScopedContentProcessorTests()
|
||||
{
|
||||
this._mockPurviewClient = new Mock<IPurviewClient>();
|
||||
this._mockCacheProvider = new Mock<ICacheProvider>();
|
||||
this._mockChannelHandler = new Mock<IChannelHandler>();
|
||||
this._processor = new ScopedContentProcessor(
|
||||
this._mockPurviewClient.Object,
|
||||
this._mockCacheProvider.Object,
|
||||
this._mockChannelHandler.Object);
|
||||
}
|
||||
|
||||
#region ProcessMessagesAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_WithBlockAccessAction_ReturnsShouldBlockTrueAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes = new List<PolicyScopeBase>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations = new List<PolicyLocation>
|
||||
{
|
||||
new ("microsoft.graph.policyLocationApplication", "app-123")
|
||||
},
|
||||
ExecutionMode = ExecutionMode.EvaluateInline
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
{
|
||||
PolicyActions = new List<DlpActionInfo>
|
||||
{
|
||||
new() { Action = DlpAction.BlockAccess }
|
||||
}
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pcResponse);
|
||||
|
||||
// Act
|
||||
var result = await this._processor.ProcessMessagesAsync(
|
||||
messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.shouldBlock);
|
||||
Assert.Equal("user-123", result.userId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_WithRestrictionActionBlock_ReturnsShouldBlockTrueAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes = new List<PolicyScopeBase>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations = new List<PolicyLocation>
|
||||
{
|
||||
new ("microsoft.graph.policyLocationApplication", "app-123")
|
||||
},
|
||||
ExecutionMode = ExecutionMode.EvaluateInline
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
{
|
||||
PolicyActions = new List<DlpActionInfo>
|
||||
{
|
||||
new() { RestrictionAction = RestrictionAction.Block }
|
||||
}
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pcResponse);
|
||||
|
||||
// Act
|
||||
var result = await this._processor.ProcessMessagesAsync(
|
||||
messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.shouldBlock);
|
||||
Assert.Equal("user-123", result.userId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_WithNoBlockingActions_ReturnsShouldBlockFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes = new List<PolicyScopeBase>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations = new List<PolicyLocation>
|
||||
{
|
||||
new("microsoft.graph.policyLocationApplication", "app-123")
|
||||
},
|
||||
ExecutionMode = ExecutionMode.EvaluateInline
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
{
|
||||
PolicyActions = new List<DlpActionInfo>
|
||||
{
|
||||
new() { Action = DlpAction.NotifyUser }
|
||||
}
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pcResponse);
|
||||
|
||||
// Act
|
||||
var result = await this._processor.ProcessMessagesAsync(
|
||||
messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.shouldBlock);
|
||||
Assert.Equal("user-123", result.userId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_UsesCachedProtectionScopes_WhenAvailableAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
var cachedPsResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes = new List<PolicyScopeBase>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations = new List<PolicyLocation>
|
||||
{
|
||||
new ("microsoft.graph.policyLocationApplication", "app-123")
|
||||
},
|
||||
ExecutionMode = ExecutionMode.EvaluateInline
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(cachedPsResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
{
|
||||
PolicyActions = new List<DlpActionInfo>()
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pcResponse);
|
||||
|
||||
// Act
|
||||
await this._processor.ProcessMessagesAsync(
|
||||
messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
this._mockPurviewClient.Verify(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_InvalidatesCache_WhenProtectionScopeModifiedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes = new List<PolicyScopeBase>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations = new List<PolicyLocation>
|
||||
{
|
||||
new ("microsoft.graph.policyLocationApplication", "app-123")
|
||||
},
|
||||
ExecutionMode = ExecutionMode.EvaluateInline
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
{
|
||||
ProtectionScopeState = ProtectionScopeState.Modified,
|
||||
PolicyActions = new List<DlpActionInfo>()
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pcResponse);
|
||||
|
||||
// Act
|
||||
await this._processor.ProcessMessagesAsync(
|
||||
messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
this._mockCacheProvider.Verify(x => x.RemoveAsync(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_SendsContentActivities_WhenNoApplicableScopesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes = new List<PolicyScopeBase>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations = new List<PolicyLocation>
|
||||
{
|
||||
new ("microsoft.graph.policyLocationApplication", "app-456")
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
// Act
|
||||
await this._processor.ProcessMessagesAsync(
|
||||
messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
// Content activities are now queued as background jobs, not called directly
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ContentActivityJob>()), Times.Once);
|
||||
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_WithNoTenantId_ThrowsPurviewExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = new PurviewSettings("TestApp"); // No TenantId
|
||||
var tokenInfo = new TokenInfo { UserId = "user-123", ClientId = "client-123" }; // No TenantId
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<PurviewRequestException>(() =>
|
||||
this._processor.ProcessMessagesAsync(messages, "thread-123", Activity.UploadText, settings, "user-123", CancellationToken.None));
|
||||
|
||||
Assert.Contains("No tenant id provided or inferred", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_WithNoUserId_ThrowsPurviewExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", ClientId = "client-123" }; // No UserId
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<PurviewRequestException>(() =>
|
||||
this._processor.ProcessMessagesAsync(messages, "thread-123", Activity.UploadText, settings, null, CancellationToken.None));
|
||||
|
||||
Assert.Contains("No user id provided or inferred", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_ExtractsUserIdFromMessageAdditionalProperties_Async()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
{
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
{ "userId", "user-from-props" }
|
||||
}
|
||||
}
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", ClientId = "client-123" };
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = new List<PolicyScopeBase>() };
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
// Act
|
||||
var result = await this._processor.ProcessMessagesAsync(
|
||||
messages, "thread-123", Activity.UploadText, settings, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("user-from-props", result.userId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_ExtractsUserIdFromMessageAuthorName_WhenValidGuidAsync()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid().ToString();
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
{
|
||||
AuthorName = userId
|
||||
}
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", ClientId = "client-123" };
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = new List<PolicyScopeBase>() };
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
// Act
|
||||
var result = await this._processor.ProcessMessagesAsync(
|
||||
messages, "thread-123", Activity.UploadText, settings, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(userId, result.userId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static PurviewSettings CreateValidPurviewSettings()
|
||||
{
|
||||
return new PurviewSettings("TestApp")
|
||||
{
|
||||
TenantId = "tenant-123",
|
||||
PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123")
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -68,7 +68,7 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture
|
||||
string name = "HelpfulAssistant",
|
||||
string instructions = "You are a helpful assistant.",
|
||||
IList<AITool>? aiTools = null) =>
|
||||
new ChatClientAgent(
|
||||
new(
|
||||
this._openAIResponseClient.AsIChatClient(),
|
||||
options: new()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user