Files
agent-framework/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentThreadTests.cs
T
westey ff3e13c2aa .Net: Add support for 3rd party thread storage and thread serialization (#203)
* Add thread storage and serialization POC

* Switch to using JsonElement and add unit tests

* Add additional unit tests.

* Exclude private debugger properties from CodeCoverage.

* Rename IChatMessagesStorable to IChatMessageStore

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Improve xml doc.

* Update the message storing thread to always use external store for both local and remote storage.

* Remove threadid from the IChatMessageStore interface, since the store should own the thread id itself, if it requires one.

* Switch GetMessages to IEnumerable

* Address pr comments.

* Make jsonserializer options default consistent on DeserializeThreadAsync

* Move message storing thread functionality into AgentThread and simplify AgentThread behavior.

* Remove embedding generation from VectorStore chat history sample.

* Remove unecessary code and fix formatting.

* Make GetNewThread and DeserializeThread virtual with default implementations.
Remove unsued json utilities.

* Fix formatting

* Remove problem test.

* Add more unit tests

* Remove unused using clause.

* Address pr feedback.

* Address PR comments.

* Make InMemory store internal

* Switch InMemoryChatMessageStore to implement IList instead of inheriting from List.

* Rename store deserialize param.

* Update serialization based on PR comments.

* Remove confusing comment.

* Address Deserialization PR comments in the same way as Serialization

* Add State to IChatMessageStore Serialize and Deserialize names.
Make Thread Deserialize internal.
Make AgentThread type switching fobidden.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-08-05 17:24:25 +00:00

330 lines
10 KiB
C#

// 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 Moq;
namespace Microsoft.Extensions.AI.Agents.Abstractions.UnitTests;
public class AgentThreadTests
{
#region Constructor and Property Tests
[Fact]
public void ConstructorSetsDefaults()
{
// Arrange & Act
var thread = new AgentThread();
// Assert
Assert.Null(thread.ConversationId);
Assert.Null(thread.MessageStore);
}
[Fact]
public void SetConversationIdRoundtrips()
{
// Arrange
var thread = new AgentThread();
var conversationid = "test-thread-id";
// Act
thread.ConversationId = conversationid;
// Assert
Assert.Equal(conversationid, thread.ConversationId);
Assert.Null(thread.MessageStore);
}
[Fact]
public void SetChatMessageStoreRoundtrips()
{
// Arrange
var thread = new AgentThread();
var messageStore = new InMemoryChatMessageStore();
// Act
thread.MessageStore = messageStore;
// Assert
Assert.Same(messageStore, thread.MessageStore);
Assert.Null(thread.ConversationId);
}
[Fact]
public void SetConversationIdThrowsWhenMessageStoreIsSet()
{
// Arrange
var thread = new AgentThread();
thread.MessageStore = new InMemoryChatMessageStore();
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => thread.ConversationId = "new-thread-id");
Assert.Equal("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported.", exception.Message);
Assert.NotNull(thread.MessageStore);
}
[Fact]
public void SetChatMessageStoreThrowsWhenConversationIdIsSet()
{
// Arrange
var thread = new AgentThread();
thread.ConversationId = "existing-thread-id";
var store = new InMemoryChatMessageStore();
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => thread.MessageStore = store);
Assert.Equal("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported.", exception.Message);
Assert.NotNull(thread.ConversationId);
}
#endregion Constructor and Property Tests
#region GetMessagesAsync Tests
[Fact]
public async Task GetMessagesAsyncReturnsEmptyListWhenNoStoreAsync()
{
// Arrange
var thread = new AgentThread();
// Act
var messages = await thread.GetMessagesAsync(CancellationToken.None).ToListAsync();
// Assert
Assert.Empty(messages);
}
[Fact]
public async Task GetMessagesAsyncReturnsEmptyListWhenAgentServiceIdAsync()
{
// Arrange
var thread = new AgentThread() { ConversationId = "thread-123" };
// Act
var messages = await thread.GetMessagesAsync(CancellationToken.None).ToListAsync();
// Assert
Assert.Empty(messages);
}
[Fact]
public async Task GetMessagesAsyncReturnsMessagesFromStoreAsync()
{
// Arrange
var store = new InMemoryChatMessageStore
{
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi there!")
};
var thread = new AgentThread() { MessageStore = store };
// Act
var messages = await thread.GetMessagesAsync(CancellationToken.None).ToListAsync();
// Assert
Assert.Equal(2, messages.Count);
Assert.Equal("Hello", messages[0].Text);
Assert.Equal("Hi there!", messages[1].Text);
}
#endregion GetMessagesAsync Tests
#region OnNewMessagesAsync Tests
[Fact]
public async Task OnNewMessagesAsyncDoesNothingWhenAgentServiceIdAsync()
{
// Arrange
var thread = new AgentThread() { ConversationId = "thread-123" };
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there!")
};
// Act
await thread.OnNewMessagesAsync(messages, CancellationToken.None);
}
[Fact]
public async Task OnNewMessagesAsyncAddsMessagesToStoreAsync()
{
// Arrange
var store = new InMemoryChatMessageStore();
var thread = new AgentThread() { MessageStore = store };
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there!")
};
// Act
await thread.OnNewMessagesAsync(messages, CancellationToken.None);
// Assert
Assert.Equal(2, store.Count);
Assert.Equal("Hello", store[0].Text);
Assert.Equal("Hi there!", store[1].Text);
}
#endregion OnNewMessagesAsync Tests
#region Deserialize Tests
[Fact]
public async Task VerifyDeserializeWithMessagesAsync()
{
// Arrange
var chatMessageStore = new InMemoryChatMessageStore();
var json = JsonSerializer.Deserialize<JsonElement>("""
{
"storeState": { "messages": [{"authorName": "testAuthor"}] }
}
""");
var thread = new AgentThread() { MessageStore = chatMessageStore };
// Act.
await thread.DeserializeAsync(json);
// Assert
Assert.Null(thread.ConversationId);
Assert.Single(chatMessageStore);
Assert.Equal("testAuthor", chatMessageStore[0].AuthorName);
}
[Fact]
public async Task VerifyDeserializeWithIdAsync()
{
// Arrange
var json = JsonSerializer.Deserialize<JsonElement>("""
{
"conversationId": "TestConvId"
}
""");
var thread = new AgentThread();
// Act
await thread.DeserializeAsync(json);
// Assert
Assert.Equal("TestConvId", thread.ConversationId);
Assert.Null(thread.MessageStore);
}
[Fact]
public async Task DeserializeWithInvalidJsonThrowsAsync()
{
// Arrange
var invalidJson = JsonSerializer.Deserialize<JsonElement>("[42]");
var thread = new AgentThread();
// Act & Assert
await Assert.ThrowsAsync<JsonException>(() => thread.DeserializeAsync(invalidJson));
}
#endregion Deserialize Tests
#region Serialize Tests
/// <summary>
/// Verify thread serialization to JSON when the thread has an id.
/// </summary>
[Fact]
public async Task VerifyThreadSerializationWithIdAsync()
{
// Arrange
var thread = new AgentThread() { ConversationId = "TestConvId" };
// Act
var json = await thread.SerializeAsync();
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.True(json.TryGetProperty("conversationId", out var idProperty));
Assert.Equal("TestConvId", idProperty.GetString());
Assert.False(json.TryGetProperty("storeState", out var storeStateProperty));
}
/// <summary>
/// Verify thread serialization to JSON when the thread has messages.
/// </summary>
[Fact]
public async Task VerifyThreadSerializationWithMessagesAsync()
{
// Arrange
var store = new InMemoryChatMessageStore();
store.Add(new ChatMessage(ChatRole.User, "TestContent") { AuthorName = "TestAuthor" });
var thread = new AgentThread() { MessageStore = store };
// Act
var json = await thread.SerializeAsync();
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.False(json.TryGetProperty("conversationId", out var idProperty));
Assert.True(json.TryGetProperty("storeState", out var storeStateProperty));
Assert.Equal(JsonValueKind.Object, storeStateProperty.ValueKind);
Assert.True(storeStateProperty.TryGetProperty("messages", out var messagesProperty));
Assert.Equal(JsonValueKind.Array, messagesProperty.ValueKind);
Assert.Single(messagesProperty.EnumerateArray());
var message = messagesProperty.EnumerateArray().First();
Assert.Equal("TestAuthor", message.GetProperty("authorName").GetString());
Assert.True(message.TryGetProperty("contents", out var contentsProperty));
Assert.Equal(JsonValueKind.Array, contentsProperty.ValueKind);
Assert.Single(contentsProperty.EnumerateArray());
var textContent = contentsProperty.EnumerateArray().First();
Assert.Equal("TestContent", textContent.GetProperty("text").GetString());
}
/// <summary>
/// Verify thread serialization to JSON with custom options.
/// </summary>
[Fact]
public async Task VerifyThreadSerializationWithCustomOptionsAsync()
{
// Arrange
var thread = new AgentThread();
JsonSerializerOptions options = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
var storeStateElement = JsonSerializer.SerializeToElement(new { Key = "TestValue" });
var messageStoreMock = new Mock<IChatMessageStore>();
messageStoreMock
.Setup(m => m.SerializeStateAsync(options, It.IsAny<CancellationToken>()))
.ReturnsAsync(storeStateElement);
thread.MessageStore = messageStoreMock.Object;
// Act
var json = await thread.SerializeAsync(options);
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.False(json.TryGetProperty("conversationId", out var idProperty));
Assert.True(json.TryGetProperty("storeState", out var storeStateProperty));
Assert.Equal(JsonValueKind.Object, storeStateProperty.ValueKind);
Assert.True(storeStateProperty.TryGetProperty("Key", out var keyProperty));
Assert.Equal("TestValue", keyProperty.GetString());
messageStoreMock.Verify(m => m.SerializeStateAsync(options, It.IsAny<CancellationToken>()), Times.Once);
}
#endregion Serialize Tests
}