mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add agent hosting package and update sample (#296)
* Add agent hosting package and update sample * Review feedback and cleanup * Include the narrator * wip * wip * Remove workaround for empty state writes. * Handle changes to AgentThread. * One more. * Fix. --------- Co-authored-by: Aditya Mandaleeka <adityam@microsoft.com>
This commit is contained in:
co-authored by
Aditya Mandaleeka
parent
8dcc8533a6
commit
e7441ee29e
+1
@@ -53,6 +53,7 @@ public class CosmosTestFixture : IAsyncLifetime
|
||||
UseSystemTextJsonSerializerWithOptions = new JsonSerializerOptions()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
TypeInfoResolver = CosmosActorStateJsonContext.Default
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI.Agents.Runtime;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AgentActor"/>.
|
||||
/// </summary>
|
||||
public class AgentActorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that calling DisposeAsync completes successfully without throwing an exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_NoException_CompletesSuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var mockContext = new Mock<IActorRuntimeContext>();
|
||||
var mockLogger = NullLoggerFactory.Instance.CreateLogger<AgentActor>();
|
||||
var actor = new AgentActor(mockAgent.Object, mockContext.Object, mockLogger);
|
||||
|
||||
// Act
|
||||
var valueTask = actor.DisposeAsync();
|
||||
|
||||
// Assert
|
||||
Assert.True(valueTask.IsCompleted, "DisposeAsync should return a completed ValueTask.");
|
||||
await valueTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI.Agents.Runtime;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="AgentProxy"/> constructor.
|
||||
/// </summary>
|
||||
public class AgentProxyTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that the constructor assigns the Name property correctly for various valid agent names.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("agent")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("特殊字符")]
|
||||
[InlineData(" a")]
|
||||
public void Constructor_ValidName_SetsNameProperty(string name)
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
|
||||
// Act
|
||||
var proxy = new AgentProxy(name, mockClient.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(name, proxy.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that GetNewThread returns a non-null <see cref="AgentProxyThread"/> instance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetNewThread_WhenCalled_ReturnsNewAgentProxyThreadInstance()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
|
||||
// Act
|
||||
AgentThread result = proxy.GetNewThread();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.IsType<AgentProxyThread>(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that consecutive calls to GetNewThread return distinct instances.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetNewThread_MultipleCalls_ReturnsDistinctInstances()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
|
||||
// Act
|
||||
AgentThread first = proxy.GetNewThread();
|
||||
AgentThread second = proxy.GetNewThread();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(first);
|
||||
Assert.NotNull(second);
|
||||
Assert.NotSame(first, second);
|
||||
}
|
||||
private const string AgentName = "agentName";
|
||||
private const string ThreadId = "thread1";
|
||||
private static readonly IReadOnlyCollection<ChatMessage> s_emptyMessages = new List<ChatMessage>();
|
||||
|
||||
private static bool IsValidGuid(string value)
|
||||
{
|
||||
return Guid.TryParse(value, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync returns a deserialized AgentRunResponse when the actor response status is Completed.
|
||||
/// Input: empty messages, threadId, Completed status with empty JSON object.
|
||||
/// Expected: AgentRunResponse with no messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenStatusIsCompleted_ReturnsDeserializedResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var mockHandle = new Mock<ActorResponseHandle>();
|
||||
var jsonElement = JsonDocument.Parse("{}").RootElement;
|
||||
var actorResponse = new ActorResponse
|
||||
{
|
||||
ActorId = new ActorId(AgentName, ThreadId),
|
||||
MessageId = "msg1",
|
||||
Data = jsonElement,
|
||||
Status = RequestStatus.Completed
|
||||
};
|
||||
mockHandle
|
||||
.Setup(h => h.GetResponseAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<ActorResponse>(actorResponse));
|
||||
mockClient
|
||||
.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy(AgentName, mockClient.Object);
|
||||
var thread = proxy.GetThread(ThreadId);
|
||||
|
||||
// Act
|
||||
var result = await proxy.RunAsync(s_emptyMessages, thread);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result.Messages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync throws an InvalidOperationException when the actor response status is Failed.
|
||||
/// Input: empty messages, threadId, Failed status.
|
||||
/// Expected: InvalidOperationException with message containing the response data.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenStatusIsFailed_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var mockHandle = new Mock<ActorResponseHandle>();
|
||||
var jsonElement = JsonDocument.Parse("{}").RootElement;
|
||||
var actorResponse = new ActorResponse
|
||||
{
|
||||
ActorId = new ActorId(AgentName, ThreadId),
|
||||
MessageId = "msg1",
|
||||
Data = jsonElement,
|
||||
Status = RequestStatus.Failed
|
||||
};
|
||||
mockHandle
|
||||
.Setup(h => h.GetResponseAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<ActorResponse>(actorResponse));
|
||||
mockClient
|
||||
.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy(AgentName, mockClient.Object);
|
||||
var thread = proxy.GetThread(ThreadId);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
proxy.RunAsync(s_emptyMessages, thread));
|
||||
Assert.Equal("The agent run request failed: {}", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync throws an InvalidOperationException when the actor response status is Pending.
|
||||
/// Input: empty messages, threadId, Pending status.
|
||||
/// Expected: InvalidOperationException with pending message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenStatusIsPending_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var mockHandle = new Mock<ActorResponseHandle>();
|
||||
var jsonElement = JsonDocument.Parse("{}").RootElement;
|
||||
var actorResponse = new ActorResponse
|
||||
{
|
||||
ActorId = new ActorId(AgentName, ThreadId),
|
||||
MessageId = "msg1",
|
||||
Data = jsonElement,
|
||||
Status = RequestStatus.Pending
|
||||
};
|
||||
mockHandle
|
||||
.Setup(h => h.GetResponseAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<ActorResponse>(actorResponse));
|
||||
mockClient
|
||||
.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy(AgentName, mockClient.Object);
|
||||
var thread = proxy.GetThread(ThreadId);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
proxy.RunAsync(s_emptyMessages, thread));
|
||||
Assert.Equal("The agent run request is still pending.", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync throws a NotSupportedException when the actor response status is unsupported.
|
||||
/// Input: empty messages, threadId, NotFound status.
|
||||
/// Expected: NotSupportedException with unsupported status message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenStatusIsUnsupported_ThrowsNotSupportedExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var mockHandle = new Mock<ActorResponseHandle>();
|
||||
var jsonElement = JsonDocument.Parse("{}").RootElement;
|
||||
var actorResponse = new ActorResponse
|
||||
{
|
||||
ActorId = new ActorId(AgentName, ThreadId),
|
||||
MessageId = "msg1",
|
||||
Data = jsonElement,
|
||||
Status = RequestStatus.NotFound
|
||||
};
|
||||
mockHandle
|
||||
.Setup(h => h.GetResponseAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<ActorResponse>(actorResponse));
|
||||
mockClient
|
||||
.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy(AgentName, mockClient.Object);
|
||||
var thread = proxy.GetThread(ThreadId);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<NotSupportedException>(() =>
|
||||
proxy.RunAsync(s_emptyMessages, thread));
|
||||
Assert.Equal($"The agent run request returned an unsupported status: {RequestStatus.NotFound}.", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that passing an AgentThread that is not an AgentProxyThread to RunStreamingAsync throws an ArgumentException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task RunStreamingAsync_InvalidThread_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("testAgent", mockClient.Object);
|
||||
AgentThread invalidThread = new Mock<AgentThread>().Object;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
{
|
||||
await foreach (var _ in proxy.RunStreamingAsync(Array.Empty<ChatMessage>(), invalidThread, cancellationToken: CancellationToken.None))
|
||||
{
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This test verifies that RunStreamingAsync completes without throwing when a valid AgentProxyThread is used.
|
||||
/// TODO: Mock IActorClient.SendRequestAsync to return an ActorResponseHandle whose WatchUpdatesAsync yields no updates.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Mocking of ActorResponseHandle.WatchUpdatesAsync with IActorClient is required")]
|
||||
public async System.Threading.Tasks.Task RunStreamingAsync_ValidProxyThread_CompletesSuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("testAgent", mockClient.Object);
|
||||
var proxyThread = new AgentProxyThread();
|
||||
|
||||
// Act & Assert
|
||||
await foreach (var _ in proxy.RunStreamingAsync(Array.Empty<ChatMessage>(), proxyThread, cancellationToken: CancellationToken.None))
|
||||
{
|
||||
// No items expected
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunStreamingAsync yields AgentRunResponseUpdate for non-failed statuses.
|
||||
/// This test uses a mock IActorClient to return an ActorResponseHandle that yields a single update with given status.
|
||||
/// Expected: The method yields the deserialized update.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(RequestStatus.Completed)]
|
||||
[InlineData(RequestStatus.Pending)]
|
||||
public async Task RunStreamingAsync_NonFailedStatus_YieldsAgentRunResponseUpdateAsync(RequestStatus status)
|
||||
{
|
||||
// Arrange
|
||||
var messages = Array.Empty<ChatMessage>();
|
||||
var threadId = "thread1";
|
||||
var expectedUpdate = new AgentRunResponseUpdate(ChatRole.Assistant, "response");
|
||||
|
||||
JsonElement jsonElement;
|
||||
if (status == RequestStatus.Completed)
|
||||
{
|
||||
// For Completed status, the implementation expects AgentRunResponse
|
||||
var agentRunResponse = new AgentRunResponse
|
||||
{
|
||||
Messages = new List<ChatMessage> { new(ChatRole.Assistant, "response") }
|
||||
};
|
||||
var responseTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse));
|
||||
jsonElement = JsonSerializer.SerializeToElement(agentRunResponse, responseTypeInfo);
|
||||
}
|
||||
else
|
||||
{
|
||||
// For Pending status, the implementation expects AgentRunResponseUpdate
|
||||
var updateTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate));
|
||||
jsonElement = JsonSerializer.SerializeToElement(expectedUpdate, updateTypeInfo);
|
||||
}
|
||||
|
||||
var actorUpdate = new ActorRequestUpdate(status, jsonElement);
|
||||
var mockHandle = new Mock<ActorResponseHandle>();
|
||||
mockHandle
|
||||
.Setup(h => h.WatchUpdatesAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(GetAsyncEnumerableAsync(actorUpdate));
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
mockClient
|
||||
.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var thread = proxy.GetThread(threadId);
|
||||
|
||||
// Act
|
||||
var results = new List<AgentRunResponseUpdate>();
|
||||
await foreach (var update in proxy.RunStreamingAsync(messages, thread))
|
||||
{
|
||||
results.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal(expectedUpdate.Text, results[0].Text);
|
||||
Assert.Equal(expectedUpdate.Role, results[0].Role);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<ActorRequestUpdate> GetAsyncEnumerableAsync(ActorRequestUpdate update)
|
||||
{
|
||||
yield return update;
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunStreamingAsync throws InvalidOperationException when an update status is Failed.
|
||||
/// Uses a mock IActorClient to return a Failed update. Expected: InvalidOperationException is thrown.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_FailedStatus_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = Array.Empty<ChatMessage>();
|
||||
var threadId = "thread1";
|
||||
var expectedUpdate = new AgentRunResponseUpdate(ChatRole.Assistant, "response");
|
||||
var updateTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate));
|
||||
var jsonElement = JsonSerializer.SerializeToElement(expectedUpdate, updateTypeInfo);
|
||||
|
||||
var actorUpdate = new ActorRequestUpdate(RequestStatus.Failed, jsonElement);
|
||||
var mockHandle = new Mock<ActorResponseHandle>();
|
||||
mockHandle
|
||||
.Setup(h => h.WatchUpdatesAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(GetAsyncEnumerableAsync(actorUpdate));
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
mockClient
|
||||
.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var thread = proxy.GetThread(threadId);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var update in proxy.RunStreamingAsync(messages, thread))
|
||||
{
|
||||
// force enumeration
|
||||
}
|
||||
});
|
||||
Assert.Contains("The agent run request failed", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that constructor throws ArgumentNullException when client is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_NullClient_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AgentProxy("agentName", null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that constructor throws ArgumentNullException when name is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AgentProxy(null!, mockClient.Object));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that constructor throws ArgumentException when name is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_EmptyName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new AgentProxy("", mockClient.Object));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync with thread overload validates null messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WithThread_NullMessages_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var thread = new AgentProxyThread();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
proxy.RunAsync(messages: null!, thread, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync with thread overload throws for invalid thread type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WithInvalidThreadType_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var invalidThread = new Mock<AgentThread>().Object;
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
proxy.RunAsync(messages, invalidThread, null, CancellationToken.None));
|
||||
Assert.Contains("thread must be an instance of AgentProxyThread", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync with thread overload creates new thread ID when thread is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WithNullThread_CreatesNewThreadIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var mockHandle = new Mock<ActorResponseHandle>();
|
||||
var response = new AgentRunResponse { Messages = [] };
|
||||
var jsonElement = JsonSerializer.SerializeToElement(response,
|
||||
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse)));
|
||||
var actorResponse = new ActorResponse
|
||||
{
|
||||
ActorId = new ActorId(AgentName, ThreadId),
|
||||
MessageId = "msg1",
|
||||
Data = jsonElement,
|
||||
Status = RequestStatus.Completed
|
||||
};
|
||||
|
||||
mockHandle.Setup(h => h.GetResponseAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<ActorResponse>(actorResponse));
|
||||
|
||||
mockClient.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
var result = await proxy.RunAsync(messages, thread: null, options: null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
mockClient.Verify(c => c.SendRequestAsync(
|
||||
It.Is<ActorRequest>(r => !string.IsNullOrEmpty(r.ActorId.Key)),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync handles cancellation properly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_CancellationRequested_ThrowsOperationCanceledExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
mockClient.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
var thread = proxy.GetThread(ThreadId);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() =>
|
||||
proxy.RunAsync(messages, thread, cancellationToken: cts.Token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunStreamingAsync with thread overload validates null messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithThread_NullMessages_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var thread = new AgentProxyThread();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(async () =>
|
||||
{
|
||||
await foreach (var _ in proxy.RunStreamingAsync(messages: null!, thread, null, CancellationToken.None))
|
||||
{
|
||||
// force enumeration
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunStreamingAsync with thread overload throws for invalid thread type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithInvalidThreadType_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var invalidThread = new Mock<AgentThread>().Object;
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
{
|
||||
await foreach (var _ in proxy.RunStreamingAsync(messages, invalidThread, null, CancellationToken.None))
|
||||
{
|
||||
// force enumeration
|
||||
}
|
||||
});
|
||||
Assert.Contains("thread must be an instance of AgentProxyThread", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunStreamingAsync handles cancellation during enumeration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_CancellationDuringEnumeration_StopsEnumerationAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
var updates = new List<ActorRequestUpdate>
|
||||
{
|
||||
new(RequestStatus.Pending, JsonSerializer.SerializeToElement(
|
||||
new AgentRunResponseUpdate(ChatRole.Assistant, "1"),
|
||||
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate)))),
|
||||
new(RequestStatus.Pending, JsonSerializer.SerializeToElement(
|
||||
new AgentRunResponseUpdate(ChatRole.Assistant, "2"),
|
||||
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate))))
|
||||
};
|
||||
|
||||
using var fakeHandle = new FakeActorResponseHandle(updates, cts);
|
||||
|
||||
mockClient.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(fakeHandle);
|
||||
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var thread = proxy.GetThread(ThreadId);
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
var receivedUpdates = new List<AgentRunResponseUpdate>();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
await foreach (var update in proxy.RunStreamingAsync(messages, thread, cancellationToken: cts.Token))
|
||||
{
|
||||
receivedUpdates.Add(update);
|
||||
}
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Single(receivedUpdates); // Only first update should be received
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync correctly uses message ID from last message if available.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesLastMessageId_WhenAvailableAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var mockHandle = new Mock<ActorResponseHandle>();
|
||||
var expectedMessageId = "custom-message-id";
|
||||
var response = new AgentRunResponse { Messages = [] };
|
||||
var jsonElement = JsonSerializer.SerializeToElement(response,
|
||||
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse)));
|
||||
var actorResponse = new ActorResponse
|
||||
{
|
||||
ActorId = new ActorId(AgentName, ThreadId),
|
||||
MessageId = expectedMessageId,
|
||||
Data = jsonElement,
|
||||
Status = RequestStatus.Completed
|
||||
};
|
||||
|
||||
mockHandle.Setup(h => h.GetResponseAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<ActorResponse>(actorResponse));
|
||||
|
||||
mockClient.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var thread = proxy.GetThread(ThreadId);
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "first"),
|
||||
new(ChatRole.User, "last") { MessageId = expectedMessageId }
|
||||
};
|
||||
|
||||
// Act
|
||||
await proxy.RunAsync(messages, thread);
|
||||
|
||||
// Assert
|
||||
mockClient.Verify(c => c.SendRequestAsync(
|
||||
It.Is<ActorRequest>(r => r.MessageId == expectedMessageId),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RunAsync generates new message ID when last message has no ID.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_GeneratesMessageId_WhenLastMessageHasNoIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var mockHandle = new Mock<ActorResponseHandle>();
|
||||
var response = new AgentRunResponse { Messages = [] };
|
||||
var jsonElement = JsonSerializer.SerializeToElement(response,
|
||||
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse)));
|
||||
var actorResponse = new ActorResponse
|
||||
{
|
||||
ActorId = new ActorId(AgentName, ThreadId),
|
||||
MessageId = "generated-id",
|
||||
Data = jsonElement,
|
||||
Status = RequestStatus.Completed
|
||||
};
|
||||
|
||||
mockHandle.Setup(h => h.GetResponseAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<ActorResponse>(actorResponse));
|
||||
|
||||
mockClient.Setup(c => c.SendRequestAsync(It.IsAny<ActorRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockHandle.Object);
|
||||
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var thread = proxy.GetThread(ThreadId);
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await proxy.RunAsync(messages, thread);
|
||||
|
||||
// Assert
|
||||
mockClient.Verify(c => c.SendRequestAsync(
|
||||
It.Is<ActorRequest>(r => !string.IsNullOrEmpty(r.MessageId) && IsValidGuid(r.MessageId)),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that GetNewThread returns unique instances with unique IDs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetNewThread_MultipleCalls_ReturnsUniqueThreadsWithUniqueIds()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<IActorClient>();
|
||||
var proxy = new AgentProxy("agentName", mockClient.Object);
|
||||
var threads = new List<AgentThread>();
|
||||
|
||||
// Act
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
threads.Add(proxy.GetNewThread());
|
||||
}
|
||||
|
||||
// Assert
|
||||
var threadIds = threads.Cast<AgentProxyThread>().Select(t => t.ConversationId).ToList();
|
||||
Assert.Equal(10, threadIds.Count);
|
||||
Assert.Equal(10, threadIds.Distinct().Count()); // All IDs should be unique
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake implementation of ActorResponseHandle for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class FakeActorResponseHandle : ActorResponseHandle
|
||||
{
|
||||
private readonly List<ActorRequestUpdate> _updates;
|
||||
private readonly CancellationTokenSource _cancellationTokenSource;
|
||||
private readonly ActorResponse? _response;
|
||||
private readonly int _delayBetweenUpdates;
|
||||
|
||||
public FakeActorResponseHandle(
|
||||
List<ActorRequestUpdate> updates,
|
||||
CancellationTokenSource cancellationTokenSource,
|
||||
ActorResponse? response = null,
|
||||
int delayBetweenUpdates = 10)
|
||||
{
|
||||
this._updates = updates;
|
||||
this._cancellationTokenSource = cancellationTokenSource;
|
||||
this._response = response;
|
||||
this._delayBetweenUpdates = delayBetweenUpdates;
|
||||
}
|
||||
|
||||
public override bool TryGetResponse([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ActorResponse? response)
|
||||
{
|
||||
response = this._response;
|
||||
return this._response != null;
|
||||
}
|
||||
|
||||
public override ValueTask<ActorResponse> GetResponseAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (this._response == null)
|
||||
{
|
||||
throw new InvalidOperationException("No response configured");
|
||||
}
|
||||
return new ValueTask<ActorResponse>(this._response);
|
||||
}
|
||||
|
||||
public override ValueTask CancelAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
this._cancellationTokenSource.Cancel();
|
||||
return default;
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<ActorRequestUpdate> WatchUpdatesAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
for (int i = 0; i < this._updates.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
yield return this._updates[i];
|
||||
|
||||
// Cancel after the first update
|
||||
if (i == 0)
|
||||
{
|
||||
this._cancellationTokenSource.Cancel();
|
||||
}
|
||||
|
||||
if (i < this._updates.Count - 1) // Don't delay after the last update
|
||||
{
|
||||
await Task.Delay(this._delayBetweenUpdates, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Hosting.UnitTests;
|
||||
|
||||
public class AgentProxyThreadTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides valid identifier values that conform to RFC 3986 unreserved characters.
|
||||
/// </summary>
|
||||
public static IEnumerable<object[]> ValidIds => new List<object[]>
|
||||
{
|
||||
new object[] { "normal" },
|
||||
new object[] { "test-id" },
|
||||
new object[] { "test_id" },
|
||||
new object[] { "test.id" },
|
||||
new object[] { "test~id" },
|
||||
new object[] { "ABC123" },
|
||||
new object[] { "a" },
|
||||
new object[] { "123" },
|
||||
new object[] { "test-id_with.various~chars" },
|
||||
new object[] { new string('a', 100) } // Long but valid ID
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Provides invalid identifier values that violate the RFC 3986 unreserved character rules.
|
||||
/// </summary>
|
||||
public static IEnumerable<object[]> InvalidIds => new List<object[]>
|
||||
{
|
||||
new object[] { " " }, // Space not allowed
|
||||
new object[] { "!@#$%^&*()" }, // Special characters not allowed
|
||||
new object[] { "test id" }, // Space not allowed
|
||||
new object[] { "test/id" }, // Forward slash not allowed
|
||||
new object[] { "test?id" }, // Question mark not allowed
|
||||
new object[] { "test#id" }, // Hash not allowed
|
||||
new object[] { "test@id" }, // At symbol not allowed
|
||||
new object[] { "test id with spaces" }, // Multiple spaces not allowed
|
||||
new object[] { "test\tid" }, // Tab not allowed
|
||||
new object[] { "test\nid" }, // Newline not allowed
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing valid id to <see cref="AgentProxyThread"/> constructor sets the Id property correctly.
|
||||
/// </summary>
|
||||
/// <param name="id">The valid identifier to test.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(ValidIds))]
|
||||
public void Constructor_ValidId_SetsIdProperty(string id)
|
||||
{
|
||||
// Act
|
||||
var thread = new AgentProxyThread(id);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(id, thread.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing invalid id to <see cref="AgentProxyThread"/> constructor throws an <see cref="ArgumentException"/>.
|
||||
/// </summary>
|
||||
/// <param name="id">The invalid identifier to test.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidIds))]
|
||||
public void Constructor_InvalidId_ThrowsArgumentException(string id)
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentException>(() => new AgentProxyThread(id));
|
||||
Assert.Contains("Thread ID", exception.Message);
|
||||
Assert.Contains("alphanumeric characters, hyphens, underscores, dots, and tildes", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing a null id to <see cref="AgentProxyThread"/> constructor throws an <see cref="ArgumentNullException"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_NullId_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AgentProxyThread(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing an empty id to <see cref="AgentProxyThread"/> constructor throws an <see cref="ArgumentException"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_EmptyId_ThrowsArgumentException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new AgentProxyThread(""));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the default constructor initializes the Id property with a valid non-empty GUID string in "N" format.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_Default_AssignsValidGuidStringAsId()
|
||||
{
|
||||
// Arrange & Act
|
||||
var thread = new AgentProxyThread();
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrEmpty(thread.ConversationId));
|
||||
Assert.True(Guid.TryParseExact(thread.ConversationId, "N", out _), $"Id '{thread.ConversationId}' is not a valid GUID in 'N' format.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that successive default constructors produce unique Id values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_Default_CreatesUniqueIds()
|
||||
{
|
||||
// Arrange & Act
|
||||
var thread1 = new AgentProxyThread();
|
||||
var thread2 = new AgentProxyThread();
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(thread1.ConversationId, thread2.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that CreateId returns a non-null, non-empty 32-character hexadecimal string without dashes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateId_ReturnsValidHexString()
|
||||
{
|
||||
// Arrange & Act
|
||||
string id = AgentProxyThread.CreateId();
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrEmpty(id));
|
||||
Assert.Equal(32, id.Length);
|
||||
Assert.Matches("^[0-9a-f]{32}$", id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that multiple calls to CreateId produce unique identifiers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateId_MultipleCalls_ReturnUniqueValues()
|
||||
{
|
||||
// Arrange & Act
|
||||
string id1 = AgentProxyThread.CreateId();
|
||||
string id2 = AgentProxyThread.CreateId();
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(id1, id2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ManyCallsInParallel produces unique values across many calls.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateId_ManyCallsInParallel_AllUnique()
|
||||
{
|
||||
// Arrange
|
||||
const int NumberOfIds = 1000;
|
||||
var ids = new string[NumberOfIds];
|
||||
|
||||
// Act - Create IDs in parallel to test thread safety
|
||||
Parallel.For(0, NumberOfIds, i =>
|
||||
{
|
||||
ids[i] = AgentProxyThread.CreateId();
|
||||
});
|
||||
|
||||
// Assert
|
||||
var uniqueIds = ids.Distinct().Count();
|
||||
Assert.Equal(NumberOfIds, uniqueIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that CreateId generates IDs that pass validation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateId_GeneratesValidIds()
|
||||
{
|
||||
// Arrange & Act
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
string id = AgentProxyThread.CreateId();
|
||||
|
||||
// Assert - Should not throw exception
|
||||
var thread = new AgentProxyThread(id);
|
||||
Assert.Equal(id, thread.ConversationId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies specific edge cases for valid IDs.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("a")]
|
||||
[InlineData("1")]
|
||||
[InlineData("_")]
|
||||
[InlineData("-")]
|
||||
[InlineData(".")]
|
||||
[InlineData("~")]
|
||||
[InlineData("a1")]
|
||||
[InlineData("test-123")]
|
||||
[InlineData("my_thread.id~1")]
|
||||
public void Constructor_ValidIdEdgeCases_SetsIdProperty(string id)
|
||||
{
|
||||
// Act
|
||||
var thread = new AgentProxyThread(id);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(id, thread.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies specific edge cases for invalid IDs.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(" leading-space")]
|
||||
[InlineData("trailing-space ")]
|
||||
[InlineData("with spaces")]
|
||||
[InlineData("with\ttab")]
|
||||
[InlineData("with\nnewline")]
|
||||
[InlineData("with/slash")]
|
||||
[InlineData("with\\backslash")]
|
||||
[InlineData("with%percent")]
|
||||
[InlineData("with+plus")]
|
||||
[InlineData("with=equals")]
|
||||
[InlineData("with?question")]
|
||||
[InlineData("with#hash")]
|
||||
[InlineData("with@at")]
|
||||
[InlineData("with[bracket")]
|
||||
[InlineData("with]bracket")]
|
||||
[InlineData("with{brace")]
|
||||
[InlineData("with}brace")]
|
||||
[InlineData("with(paren")]
|
||||
[InlineData("with)paren")]
|
||||
[InlineData("with!exclamation")]
|
||||
[InlineData("with*asterisk")]
|
||||
[InlineData("with:colon")]
|
||||
[InlineData("with;semicolon")]
|
||||
[InlineData("with,comma")]
|
||||
[InlineData("with\"quote")]
|
||||
[InlineData("with'apostrophe")]
|
||||
public void Constructor_InvalidIdEdgeCases_ThrowsArgumentException(string id)
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentException>(() => new AgentProxyThread(id));
|
||||
Assert.Contains("Thread ID", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AgentProxyThread inherits from AgentThread.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AgentProxyThread_InheritsFromAgentThread()
|
||||
{
|
||||
// Arrange & Act
|
||||
var thread = new AgentProxyThread();
|
||||
|
||||
// Assert
|
||||
Assert.IsAssignableFrom<AgentThread>(thread);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Id property is accessible.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Id_IsAccessible()
|
||||
{
|
||||
// Arrange & Act
|
||||
var thread = new AgentProxyThread("test-id");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(thread.ConversationId);
|
||||
Assert.Equal("test-id", thread.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that thread ID remains immutable after construction.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Id_IsImmutable()
|
||||
{
|
||||
// Arrange
|
||||
const string OriginalId = "immutable-id";
|
||||
var thread = new AgentProxyThread(OriginalId);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal(OriginalId, thread.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that default constructor creates thread with valid GUID format.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_Default_AlwaysCreatesValidGuid()
|
||||
{
|
||||
// Arrange & Act
|
||||
var thread = new AgentProxyThread();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(thread.ConversationId);
|
||||
Assert.Equal(32, thread.ConversationId.Length);
|
||||
Assert.True(Guid.TryParseExact(thread.ConversationId, "N", out var guid));
|
||||
Assert.NotEqual(Guid.Empty, guid);
|
||||
}
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Hosting.UnitTests;
|
||||
|
||||
public class HostApplicationBuilderAgentExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that providing a null builder to AddAIAgent throws an ArgumentNullException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_NullBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => HostApplicationBuilderAgentExtensions.AddAIAgent(null!, "agent", "instructions"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with valid parameters returns the same builder instance.
|
||||
/// </summary>
|
||||
/// <param name="chatClientKey">The chat client key to use, or null to use the default service.</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("customKey")]
|
||||
public void AddAIAgent_ValidParameters_ReturnsBuilder(string? chatClientKey)
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent("agentName", "instructions", chatClientKey);
|
||||
|
||||
// Assert
|
||||
Assert.Same(builder, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent without chat client key throws ArgumentNullException for null name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddAIAgent(null!, "instructions"));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent without chat client key allows null instructions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_NullInstructions_AllowsNull()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent("agentName", (string)null!);
|
||||
|
||||
// Assert
|
||||
Assert.Same(builder, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with chat client key throws ArgumentNullException for null name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithKey_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddAIAgent(null!, "instructions", "key"));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with chat client key allows null instructions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithKey_NullInstructions_AllowsNull()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent("agentName", null!, "key");
|
||||
|
||||
// Assert
|
||||
Assert.Same(builder, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null builder.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithFactory_NullBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
HostApplicationBuilderAgentExtensions.AddAIAgent(
|
||||
null!,
|
||||
"agentName",
|
||||
(sp, key) => new Mock<AIAgent>().Object));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithFactory_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddAIAgent(null!, (sp, key) => new Mock<AIAgent>().Object));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null factory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithFactory_NullFactory_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddAIAgent("agentName", (Func<IServiceProvider, string, AIAgent>)null!));
|
||||
Assert.Equal("createAgentDelegate", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with factory delegate returns the same builder instance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithFactory_ValidParameters_ReturnsBuilder()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent("agentName", (sp, key) => mockAgent.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(builder, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent registers the agent as a keyed singleton service.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_RegistersKeyedSingleton()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
const string AgentName = "testAgent";
|
||||
|
||||
// Act
|
||||
builder.AddAIAgent(AgentName, (sp, key) => mockAgent.Object);
|
||||
|
||||
// Assert
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => d.ServiceKey as string == AgentName &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent can be called multiple times with different agent names.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_MultipleCalls_RegistersMultipleAgents()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act
|
||||
builder.AddAIAgent("agent1", "instructions1")
|
||||
.AddAIAgent("agent2", "instructions2")
|
||||
.AddAIAgent("agent3", "instructions3");
|
||||
|
||||
// Assert
|
||||
var agentDescriptors = builder.Services
|
||||
.Where(d => d.ServiceType == typeof(AIAgent) && d.ServiceKey is string)
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(3, agentDescriptors.Count);
|
||||
Assert.Contains(agentDescriptors, d => (string)d.ServiceKey! == "agent1");
|
||||
Assert.Contains(agentDescriptors, d => (string)d.ServiceKey! == "agent2");
|
||||
Assert.Contains(agentDescriptors, d => (string)d.ServiceKey! == "agent3");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent handles empty strings for name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_EmptyName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
builder.AddAIAgent("", "instructions"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent allows empty strings for instructions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_EmptyInstructions_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent("agentName", "");
|
||||
|
||||
// Assert
|
||||
Assert.Same(builder, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with whitespace name throws ArgumentException.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\t")]
|
||||
[InlineData(" agent ")]
|
||||
public void AddAIAgent_WhitespaceName_ThrowsArgumentException(string name)
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentException>(() =>
|
||||
builder.AddAIAgent(name, "instructions"));
|
||||
Assert.Contains("Invalid type", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent without chat client key calls the overload with null key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_WithoutKey_CallsOverloadWithNullKey()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent("agentName", "instructions");
|
||||
|
||||
// Assert
|
||||
Assert.Same(builder, result);
|
||||
// The agent should be registered (proving the method chain worked)
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => d.ServiceKey as string == "agentName" &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
Assert.NotNull(descriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with special characters in name works correctly for valid names.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("agent_name")] // underscore is allowed
|
||||
[InlineData("Agent123")] // alphanumeric is allowed
|
||||
[InlineData("_agent")] // can start with underscore
|
||||
[InlineData("agent-name")] // dash is allowed
|
||||
[InlineData("agent.name")] // period is allowed
|
||||
[InlineData("agent:type")] // colon is allowed
|
||||
[InlineData("my.agent_1:type-name")] // complex valid name
|
||||
public void AddAIAgent_ValidSpecialCharactersInName_Succeeds(string name)
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent(name, "instructions");
|
||||
|
||||
// Assert
|
||||
Assert.Same(builder, result);
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => d.ServiceKey as string == name &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
Assert.NotNull(descriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with invalid special characters throws ArgumentException.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("特殊字符")] // non-ASCII not allowed
|
||||
[InlineData("123agent")] // cannot start with number
|
||||
[InlineData("agent@name")] // @ not allowed
|
||||
[InlineData("agent/name")] // / not allowed
|
||||
[InlineData("agent name")] // space not allowed
|
||||
[InlineData(".agent")] // cannot start with period
|
||||
[InlineData("-agent")] // cannot start with dash
|
||||
[InlineData(":agent")] // cannot start with colon
|
||||
public void AddAIAgent_InvalidSpecialCharactersInName_ThrowsArgumentException(string name)
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentException>(() =>
|
||||
builder.AddAIAgent(name, "instructions"));
|
||||
Assert.Contains("Invalid type", exception.Message);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Extensions.AI.Agents.Hosting\Microsoft.Extensions.AI.Agents.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.Runtime.UnitTests;
|
||||
|
||||
public class ActorTypeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides valid ActorType names that conform to the regex pattern ^[a-zA-Z_][a-zA-Z._:\-0-9]*$.
|
||||
/// </summary>
|
||||
public static IEnumerable<object[]> ValidActorTypeNames => new List<object[]>
|
||||
{
|
||||
new object[] { "a" }, // Single letter
|
||||
new object[] { "A" }, // Single uppercase letter
|
||||
new object[] { "_" }, // Single underscore
|
||||
new object[] { "agent" }, // Simple name
|
||||
new object[] { "Agent" }, // Capitalized name
|
||||
new object[] { "AGENT" }, // All caps name
|
||||
new object[] { "my_agent" }, // With underscore
|
||||
new object[] { "MyAgent" }, // Camel case
|
||||
new object[] { "agent1" }, // With number
|
||||
new object[] { "agent_1" }, // With underscore and number
|
||||
new object[] { "agent:type" }, // With colon
|
||||
new object[] { "agent-type" }, // With hyphen
|
||||
new object[] { "my_agent:type-1" }, // Complex valid name
|
||||
new object[] { "A1_test:complex-name" }, // Very complex valid name
|
||||
new object[] { "_private_agent" }, // Starting with underscore
|
||||
new object[] { "agent_with_many_underscores" }, // Multiple underscores
|
||||
new object[] { "agent:with:colons" }, // Multiple colons
|
||||
new object[] { "agent-with-hyphens" }, // Multiple hyphens
|
||||
new object[] { "agent123456789" }, // With many numbers
|
||||
new object[] { "agent.type" }, // With dot
|
||||
new object[] { "agent.sub.type" }, // With multiple dots
|
||||
new object[] { "my.agent_1:type-name" }, // Complex with dots
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Provides invalid ActorType names that violate the regex pattern ^[a-zA-Z_][a-zA-Z._:\-0-9]*$.
|
||||
/// </summary>
|
||||
public static IEnumerable<object[]> InvalidActorTypeNames => new List<object[]>
|
||||
{
|
||||
new object[] { "1agent" }, // Starting with number
|
||||
new object[] { "9test" }, // Starting with number
|
||||
new object[] { "-agent" }, // Starting with hyphen
|
||||
new object[] { ":agent" }, // Starting with colon
|
||||
new object[] { " agent" }, // Starting with space
|
||||
new object[] { "agent " }, // Trailing space
|
||||
new object[] { "agent agent" }, // Space in middle
|
||||
new object[] { "agent@type" }, // Invalid character @
|
||||
new object[] { "agent#type" }, // Invalid character #
|
||||
new object[] { "agent$type" }, // Invalid character $
|
||||
new object[] { "agent%type" }, // Invalid character %
|
||||
new object[] { "agent^type" }, // Invalid character ^
|
||||
new object[] { "agent&type" }, // Invalid character &
|
||||
new object[] { "agent*type" }, // Invalid character *
|
||||
new object[] { "agent(type)" }, // Invalid characters ( )
|
||||
new object[] { "agent[type]" }, // Invalid characters [ ]
|
||||
new object[] { "agent{type}" }, // Invalid characters { }
|
||||
new object[] { "agent+type" }, // Invalid character +
|
||||
new object[] { "agent=type" }, // Invalid character =
|
||||
new object[] { "agent\\type" }, // Invalid character \
|
||||
new object[] { "agent/type" }, // Invalid character /
|
||||
new object[] { "agent?type" }, // Invalid character ?
|
||||
new object[] { "agent,type" }, // Invalid character ,
|
||||
new object[] { "agent;type" }, // Invalid character ;
|
||||
new object[] { "agent\"type" }, // Invalid character "
|
||||
new object[] { "agent'type" }, // Invalid character '
|
||||
new object[] { "agent`type" }, // Invalid character `
|
||||
new object[] { "agent~type" }, // Invalid character ~
|
||||
new object[] { "agent!type" }, // Invalid character !
|
||||
new object[] { "agent\ttype" }, // Tab character
|
||||
new object[] { "agent\ntype" }, // Newline character
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing valid actor type name to <see cref="ActorType"/> constructor sets the Name property correctly.
|
||||
/// </summary>
|
||||
/// <param name="typeName">The valid type name to test.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(ValidActorTypeNames))]
|
||||
public void Constructor_ValidTypeName_SetsNameProperty(string typeName)
|
||||
{
|
||||
// Act
|
||||
var actorType = new ActorType(typeName);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(typeName, actorType.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing invalid actor type name to <see cref="ActorType"/> constructor throws an <see cref="ArgumentException"/>.
|
||||
/// </summary>
|
||||
/// <param name="typeName">The invalid type name to test.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidActorTypeNames))]
|
||||
public void Constructor_InvalidTypeName_ThrowsArgumentException(string typeName)
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentException>(() => new ActorType(typeName));
|
||||
Assert.Contains("Invalid type", exception.Message);
|
||||
Assert.Contains("Must start with a letter or underscore, and can only contain letters, dots, underscores, colons, hyphens, and numbers", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing a null type name to <see cref="ActorType"/> constructor throws an <see cref="ArgumentNullException"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_NullTypeName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new ActorType(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that providing an empty type name to <see cref="ActorType"/> constructor throws an <see cref="ArgumentException"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_EmptyTypeName_ThrowsArgumentException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new ActorType(""));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies specific edge cases for valid type names.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("a")]
|
||||
[InlineData("Z")]
|
||||
[InlineData("_")]
|
||||
[InlineData("a1")]
|
||||
[InlineData("_1")]
|
||||
[InlineData("agent_123")]
|
||||
[InlineData("MyAgent:SubType")]
|
||||
[InlineData("my-agent")]
|
||||
[InlineData("agent_type:sub-type_123")]
|
||||
[InlineData("agent.type")]
|
||||
[InlineData("my.agent.name")]
|
||||
[InlineData("complex.name_1:type-sub")]
|
||||
public void Constructor_ValidTypeNameEdgeCases_SetsNameProperty(string typeName)
|
||||
{
|
||||
// Act
|
||||
var actorType = new ActorType(typeName);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(typeName, actorType.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies specific edge cases for invalid type names.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("1")]
|
||||
[InlineData("9")]
|
||||
[InlineData("-")]
|
||||
[InlineData(":")]
|
||||
[InlineData("1agent")]
|
||||
[InlineData("-agent")]
|
||||
[InlineData(":agent")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("agent ")]
|
||||
[InlineData(" agent")]
|
||||
[InlineData("a b")]
|
||||
[InlineData("agent@type")]
|
||||
[InlineData("agent/type")]
|
||||
public void Constructor_InvalidTypeNameEdgeCases_ThrowsArgumentException(string typeName)
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentException>(() => new ActorType(typeName));
|
||||
Assert.Contains("Invalid type", exception.Message);
|
||||
Assert.Contains("Must start with a letter or underscore", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ToString returns the type name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToString_ReturnsTypeName()
|
||||
{
|
||||
// Arrange
|
||||
const string TypeName = "test_agent";
|
||||
var actorType = new ActorType(TypeName);
|
||||
|
||||
// Act
|
||||
string result = actorType.ToString();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TypeName, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies equality comparison between ActorType instances.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Equals_SameTypeName_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var actorType1 = new ActorType("test_agent");
|
||||
var actorType2 = new ActorType("test_agent");
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(actorType1.Equals(actorType2));
|
||||
Assert.True(actorType1 == actorType2);
|
||||
Assert.False(actorType1 != actorType2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies inequality comparison between ActorType instances.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Equals_DifferentTypeName_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var actorType1 = new ActorType("test_agent1");
|
||||
var actorType2 = new ActorType("test_agent2");
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(actorType1.Equals(actorType2));
|
||||
Assert.False(actorType1 == actorType2);
|
||||
Assert.True(actorType1 != actorType2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that GetHashCode returns same value for equal instances.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetHashCode_SameTypeName_ReturnsSameHashCode()
|
||||
{
|
||||
// Arrange
|
||||
var actorType1 = new ActorType("test_agent");
|
||||
var actorType2 = new ActorType("test_agent");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal(actorType1.GetHashCode(), actorType2.GetHashCode());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ActorType is case sensitive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Equality_IsCaseSensitive()
|
||||
{
|
||||
// Arrange
|
||||
var actorType1 = new ActorType("TestAgent");
|
||||
var actorType2 = new ActorType("testagent");
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(actorType1.Equals(actorType2));
|
||||
Assert.False(actorType1 == actorType2);
|
||||
Assert.True(actorType1 != actorType2);
|
||||
Assert.NotEqual(actorType1.GetHashCode(), actorType2.GetHashCode());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsValidType static method works correctly for valid names.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(ValidActorTypeNames))]
|
||||
public void IsValidType_ValidTypeName_ReturnsTrue(string typeName)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.True(ActorType.IsValidType(typeName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsValidType static method works correctly for invalid names.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidActorTypeNames))]
|
||||
public void IsValidType_InvalidTypeName_ReturnsFalse(string typeName)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.False(ActorType.IsValidType(typeName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsValidType throws for null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsValidType_NullTypeName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ActorType.IsValidType(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsValidType throws for empty string.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsValidType_EmptyTypeName_ThrowsArgumentException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => ActorType.IsValidType(""));
|
||||
}
|
||||
}
|
||||
+201
-1
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -19,7 +20,7 @@ public class JsonSerializationTests
|
||||
WriteIndented = false, // Use compact JSON for easier testing
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
TypeInfoResolver = ActorJsonContext.Default
|
||||
TypeInfoResolver = AgentRuntimeAbstractionsJsonUtilities.JsonContext.Default
|
||||
};
|
||||
}
|
||||
|
||||
@@ -332,4 +333,203 @@ public class JsonSerializationTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ActorResponse Tests
|
||||
|
||||
[Fact]
|
||||
public void ActorResponse_SerializesAndDeserializes()
|
||||
{
|
||||
// Arrange
|
||||
var originalResponse = new ActorResponse
|
||||
{
|
||||
ActorId = new ActorId("TestActor", "instance1"),
|
||||
MessageId = "msg123",
|
||||
Status = RequestStatus.Completed,
|
||||
Data = JsonSerializer.SerializeToElement(new { result = "success" })
|
||||
};
|
||||
|
||||
// Act - Serialize to JSON
|
||||
string json = JsonSerializer.Serialize(originalResponse, this._options);
|
||||
|
||||
// Assert - JSON structure
|
||||
Assert.Contains("\"messageId\":\"msg123\"", json);
|
||||
Assert.Contains("\"status\":\"completed\"", json);
|
||||
|
||||
// Act - Deserialize back
|
||||
var deserializedResponse = JsonSerializer.Deserialize<ActorResponse>(json, this._options);
|
||||
|
||||
// Assert - Verify deserialization
|
||||
Assert.NotNull(deserializedResponse);
|
||||
Assert.Equal(originalResponse.MessageId, deserializedResponse.MessageId);
|
||||
Assert.Equal(originalResponse.Status, deserializedResponse.Status);
|
||||
Assert.Equal(originalResponse.ActorId.Type.Name, deserializedResponse.ActorId.Type.Name);
|
||||
Assert.Equal(originalResponse.ActorId.Key, deserializedResponse.ActorId.Key);
|
||||
Assert.Equal(originalResponse.Data.GetRawText(), deserializedResponse.Data.GetRawText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActorResponse_ToString_OutputsExpectedFormat()
|
||||
{
|
||||
// Arrange
|
||||
var testData = JsonSerializer.SerializeToElement(new { result = "success" });
|
||||
var response = new ActorResponse
|
||||
{
|
||||
ActorId = new ActorId("TestActor", "instance1"),
|
||||
MessageId = "msg123",
|
||||
Status = RequestStatus.Completed,
|
||||
Data = testData
|
||||
};
|
||||
|
||||
// Act
|
||||
string result = response.ToString();
|
||||
|
||||
// Assert
|
||||
Assert.Equal($"ActorResponse(ActorId: TestActor/instance1, Status: Completed, MessageId: msg123, Data: {testData.GetRawText()})", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActorResponse_ToString_WithNullMessageId_OutputsExpectedFormat()
|
||||
{
|
||||
// Arrange
|
||||
var testData = JsonSerializer.SerializeToElement(new { error = "timeout" });
|
||||
var response = new ActorResponse
|
||||
{
|
||||
ActorId = new ActorId("TestActor", "instance1"),
|
||||
MessageId = null,
|
||||
Status = RequestStatus.Pending,
|
||||
Data = testData
|
||||
};
|
||||
|
||||
// Act
|
||||
string result = response.ToString();
|
||||
|
||||
// Assert
|
||||
Assert.Equal($"ActorResponse(ActorId: TestActor/instance1, Status: Pending, MessageId: null, Data: {testData.GetRawText()})", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActorResponse_ToString_WithEmptyData_OutputsExpectedFormat()
|
||||
{
|
||||
// Arrange
|
||||
var emptyData = new JsonElement(); // Default JsonElement (empty)
|
||||
var response = new ActorResponse
|
||||
{
|
||||
ActorId = new ActorId("TestActor", "instance1"),
|
||||
MessageId = "msg456",
|
||||
Status = RequestStatus.Failed,
|
||||
Data = emptyData
|
||||
};
|
||||
|
||||
// Act
|
||||
string result = response.ToString();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("ActorResponse(ActorId: TestActor/instance1, Status: Failed, MessageId: msg456, Data: undefined)", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActorResponse_ToString_WithLargeData_TruncatesAfter250Characters()
|
||||
{
|
||||
// Arrange
|
||||
// Create a large object that will serialize to more than 250 characters
|
||||
var largeArray = new List<object>();
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
largeArray.Add(new
|
||||
{
|
||||
id = $"item-{i:000}",
|
||||
name = $"This is item number {i} with a long description to make the JSON larger",
|
||||
properties = new
|
||||
{
|
||||
prop1 = $"value1-{i}",
|
||||
prop2 = $"value2-{i}",
|
||||
prop3 = $"value3-{i}",
|
||||
prop4 = $"value4-{i}",
|
||||
prop5 = $"value5-{i}"
|
||||
}
|
||||
});
|
||||
}
|
||||
var largeData = JsonSerializer.SerializeToElement(largeArray);
|
||||
var response = new ActorResponse
|
||||
{
|
||||
ActorId = new ActorId("TestActor", "instance1"),
|
||||
MessageId = "msg789",
|
||||
Status = RequestStatus.Completed,
|
||||
Data = largeData
|
||||
};
|
||||
|
||||
// Act
|
||||
string result = response.ToString();
|
||||
var rawText = largeData.GetRawText();
|
||||
|
||||
// Assert
|
||||
// Verify that the raw JSON is indeed larger than 250 characters
|
||||
Assert.True(rawText.Length > 250, $"Test data should be larger than 250 characters, but was {rawText.Length}");
|
||||
|
||||
// The ToString should truncate the data and add "..."
|
||||
Assert.EndsWith("...)", result);
|
||||
|
||||
// Extract the data portion from the result
|
||||
var dataStartIndex = result.IndexOf("Data: ", System.StringComparison.Ordinal) + 6;
|
||||
var dataEndIndex = result.Length - 1; // Exclude the closing parenthesis
|
||||
var dataInResult = result.Substring(dataStartIndex, dataEndIndex - dataStartIndex);
|
||||
|
||||
// Verify truncation: data should be 253 characters (250 + "...")
|
||||
Assert.Equal(253, dataInResult.Length);
|
||||
|
||||
// Verify that the truncated data matches the first 250 characters of the original
|
||||
#pragma warning disable CA1846 // Prefer 'AsSpan' over 'Substring'
|
||||
Assert.Equal(rawText.Substring(0, 250), dataInResult.Substring(0, 250));
|
||||
#pragma warning restore CA1846
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActorResponse_ToString_WithSmallData_DoesNotTruncate()
|
||||
{
|
||||
// Arrange
|
||||
var smallObject = new
|
||||
{
|
||||
id = "test-id-123",
|
||||
name = "Small Test Object",
|
||||
value = 42
|
||||
};
|
||||
var smallData = JsonSerializer.SerializeToElement(smallObject);
|
||||
var response = new ActorResponse
|
||||
{
|
||||
ActorId = new ActorId("TestActor", "instance1"),
|
||||
MessageId = "msg789",
|
||||
Status = RequestStatus.Completed,
|
||||
Data = smallData
|
||||
};
|
||||
|
||||
// Act
|
||||
string result = response.ToString();
|
||||
|
||||
// Assert
|
||||
// The ToString should include the full JSON data without truncation
|
||||
Assert.Equal($"ActorResponse(ActorId: TestActor/instance1, Status: Completed, MessageId: msg789, Data: {smallData.GetRawText()})", result);
|
||||
// Verify no truncation occurred
|
||||
Assert.DoesNotContain("...", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActorResponse_ToString_WithNullData_OutputsExpectedFormat()
|
||||
{
|
||||
// Arrange
|
||||
var response = new ActorResponse
|
||||
{
|
||||
ActorId = new ActorId("TestActor", "instance1"),
|
||||
MessageId = "msg999",
|
||||
Status = RequestStatus.Completed,
|
||||
Data = JsonSerializer.SerializeToElement((object?)null)
|
||||
};
|
||||
|
||||
// Act
|
||||
string result = response.ToString();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("ActorResponse(ActorId: TestActor/instance1, Status: Completed, MessageId: msg999, Data: null)", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user