// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Azure.Core; using Azure.Identity; using Microsoft.Azure.Cosmos; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests; /// /// Contains tests for . /// /// Test Modes: /// - Default Mode: Cleans up all test data after each test run (deletes database) /// - Preserve Mode: Keeps containers and data for inspection in Cosmos DB Emulator Data Explorer /// /// To enable Preserve Mode, set environment variable: COSMOSDB_PRESERVE_CONTAINERS=true /// Example: $env:COSMOSDB_PRESERVE_CONTAINERS="true"; dotnet test /// /// In Preserve Mode, you can view the data in Cosmos DB Emulator Data Explorer at: /// https://localhost:8081/_explorer/index.html /// Database: AgentFrameworkTests /// Container: ChatMessages /// /// Environment Variable Reference: /// | Variable | Values | Description | /// |----------|--------|-------------| /// | COSMOSDB_PRESERVE_CONTAINERS | true / false | Controls whether to preserve test data after completion | /// /// Usage Examples: /// - Run all tests in preserve mode: $env:COSMOSDB_PRESERVE_CONTAINERS="true"; dotnet test tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/ /// - Run specific test category in preserve mode: $env:COSMOSDB_PRESERVE_CONTAINERS="true"; dotnet test tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/ --filter "Category=CosmosDB" /// - Reset to cleanup mode: $env:COSMOSDB_PRESERVE_CONTAINERS=""; dotnet test tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/ /// [Collection("CosmosDB")] public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable { private static readonly AIAgent s_mockAgent = new Moq.Mock().Object; private static AgentSession CreateMockSession() => new Moq.Mock().Object; // Cosmos DB Emulator connection settings (can be overridden via COSMOSDB_ENDPOINT and COSMOSDB_KEY environment variables) private static readonly string s_emulatorEndpoint = Environment.GetEnvironmentVariable("COSMOSDB_ENDPOINT") ?? "https://localhost:8081"; private static readonly string s_emulatorKey = Environment.GetEnvironmentVariable("COSMOSDB_KEY") ?? "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; private const string TestContainerId = "ChatMessages"; private const string HierarchicalTestContainerId = "HierarchicalChatMessages"; // Use unique database ID per test class instance to avoid conflicts #pragma warning disable CA1802 // Use literals where appropriate private static readonly string s_testDatabaseId = $"AgentFrameworkTests-ChatStore-{Guid.NewGuid():N}"; #pragma warning restore CA1802 private string _connectionString = string.Empty; private bool _emulatorAvailable; private bool _preserveContainer; private CosmosClient? _setupClient; // Only used for test setup/cleanup public async ValueTask InitializeAsync() { // Fail fast if emulator is not available this.SkipIfEmulatorNotAvailable(); // Check environment variable to determine if we should preserve containers // Set COSMOSDB_PRESERVE_CONTAINERS=true to keep containers and data for inspection this._preserveContainer = string.Equals(Environment.GetEnvironmentVariable("COSMOSDB_PRESERVE_CONTAINERS"), bool.TrueString, StringComparison.OrdinalIgnoreCase); this._connectionString = $"AccountEndpoint={s_emulatorEndpoint};AccountKey={s_emulatorKey}"; try { // Only create CosmosClient for test setup - the actual tests will use connection string constructors this._setupClient = new CosmosClient(s_emulatorEndpoint, s_emulatorKey); // Test connection by attempting to create database var databaseResponse = await this._setupClient.CreateDatabaseIfNotExistsAsync(s_testDatabaseId); // Create container for simple partitioning tests await databaseResponse.Database.CreateContainerIfNotExistsAsync( TestContainerId, "/conversationId", throughput: 400); // Create container for hierarchical partitioning tests with hierarchical partition key var hierarchicalContainerProperties = new ContainerProperties(HierarchicalTestContainerId, ["/tenantId", "/userId", "/sessionId"]); await databaseResponse.Database.CreateContainerIfNotExistsAsync( hierarchicalContainerProperties, throughput: 400); this._emulatorAvailable = true; } catch (Exception) { // Emulator not available, tests will be skipped this._emulatorAvailable = false; this._setupClient?.Dispose(); this._setupClient = null; } } public async ValueTask DisposeAsync() { GC.SuppressFinalize(this); if (this._setupClient != null && this._emulatorAvailable) { try { if (this._preserveContainer) { // Preserve mode: Don't delete the database/container, keep data for inspection // This allows viewing data in the Cosmos DB Emulator Data Explorer // No cleanup needed - data persists for debugging } else { // Clean mode: Delete the test database and all data var database = this._setupClient.GetDatabase(s_testDatabaseId); await database.DeleteAsync(); } } catch (Exception ex) { // Ignore cleanup errors during test teardown Console.WriteLine($"Warning: Cleanup failed: {ex.Message}"); } finally { this._setupClient.Dispose(); } } } public void Dispose() { this._setupClient?.Dispose(); GC.SuppressFinalize(this); } private void SkipIfEmulatorNotAvailable() { // In CI: Skip if COSMOSDB_EMULATOR_AVAILABLE is not set to "true" // Locally: Skip if emulator connection check failed var ciEmulatorAvailable = string.Equals(Environment.GetEnvironmentVariable("COSMOSDB_EMULATOR_AVAILABLE"), bool.TrueString, StringComparison.OrdinalIgnoreCase); Assert.SkipWhen(!ciEmulatorAvailable && !this._emulatorAvailable, "Cosmos DB Emulator is not available"); } #region Constructor Tests [Fact] [Trait("Category", "CosmosDB")] public void StateKeys_ReturnsDefaultKey_WhenNoStateKeyProvided() { // Arrange & Act this.SkipIfEmulatorNotAvailable(); using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State("test-conversation")); // Assert Assert.Single(provider.StateKeys); Assert.Contains("CosmosChatHistoryProvider", provider.StateKeys); } [Fact] [Trait("Category", "CosmosDB")] public void StateKeys_ReturnsCustomKey_WhenSetViaConstructor() { // Arrange & Act this.SkipIfEmulatorNotAvailable(); using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State("test-conversation"), stateKey: "custom-key"); // Assert Assert.Single(provider.StateKeys); Assert.Contains("custom-key", provider.StateKeys); } [Fact] [Trait("Category", "CosmosDB")] public void Constructor_WithConnectionString_ShouldCreateInstance() { // Arrange & Act this.SkipIfEmulatorNotAvailable(); // Act using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State("test-conversation")); // Assert Assert.NotNull(provider); Assert.Equal(s_testDatabaseId, provider.DatabaseId); Assert.Equal(TestContainerId, provider.ContainerId); } [Fact] [Trait("Category", "CosmosDB")] public void Constructor_WithNullConnectionString_ShouldThrowArgumentException() { // Arrange & Act & Assert Assert.Throws(() => new CosmosChatHistoryProvider((string)null!, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State("test-conversation"))); } [Fact] [Trait("Category", "CosmosDB")] public void Constructor_WithNullStateInitializer_ShouldThrowArgumentNullException() { // Arrange & Act & Assert this.SkipIfEmulatorNotAvailable(); Assert.Throws(() => new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, null!)); } #endregion #region InvokedAsync Tests [Fact] [Trait("Category", "CosmosDB")] public async Task InvokedAsync_WithSingleMessage_ShouldAddMessageAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); var conversationId = Guid.NewGuid().ToString(); using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(conversationId)); var message = new ChatMessage(ChatRole.User, "Hello, world!"); var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, [message], []); // Act await provider.InvokedAsync(context); // Wait a moment for eventual consistency await Task.Delay(100); // Assert var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var messages = await provider.InvokingAsync(invokingContext); var messageList = messages.ToList(); // Simple assertion - if this fails, we know the deserialization is the issue if (messageList.Count == 0) { // Let's check if we can find ANY items in the container for this conversation var directQuery = new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.conversationId = @conversationId") .WithParameter("@conversationId", conversationId); var countIterator = this._setupClient!.GetDatabase(s_testDatabaseId).GetContainer(TestContainerId) .GetItemQueryIterator(directQuery, requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey(conversationId) }); var countResponse = await countIterator.ReadNextAsync(); var count = countResponse.FirstOrDefault(); // Debug: Let's see what the raw query returns var rawQuery = new QueryDefinition("SELECT * FROM c WHERE c.conversationId = @conversationId") .WithParameter("@conversationId", conversationId); var rawIterator = this._setupClient!.GetDatabase(s_testDatabaseId).GetContainer(TestContainerId) .GetItemQueryIterator(rawQuery, requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey(conversationId) }); List rawResults = []; while (rawIterator.HasMoreResults) { var rawResponse = await rawIterator.ReadNextAsync(); rawResults.AddRange(rawResponse); } string rawJson = rawResults.Count > 0 ? Newtonsoft.Json.JsonConvert.SerializeObject(rawResults[0], Newtonsoft.Json.Formatting.Indented) : "null"; Assert.Fail($"InvokingAsync returned 0 messages, but direct count query found {count} items for conversation {conversationId}. Raw document: {rawJson}"); } Assert.Single(messageList); Assert.Equal("Hello, world!", messageList[0].Text); Assert.Equal(ChatRole.User, messageList[0].Role); } [Fact] [Trait("Category", "CosmosDB")] public async Task InvokedAsync_WithMultipleMessages_ShouldAddAllMessagesAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); var conversationId = Guid.NewGuid().ToString(); using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(conversationId)); var requestMessages = new[] { new ChatMessage(ChatRole.User, "First message"), new ChatMessage(ChatRole.Assistant, "Second message"), new ChatMessage(ChatRole.User, "Third message"), new ChatMessage(ChatRole.System, "System context message") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "TestSource") } } } }; var responseMessages = new[] { new ChatMessage(ChatRole.Assistant, "Response message") }; var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, requestMessages, responseMessages); // Act await provider.InvokedAsync(context); // Assert var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var retrievedMessages = await provider.InvokingAsync(invokingContext); var messageList = retrievedMessages.ToList(); Assert.Equal(5, messageList.Count); Assert.Equal("First message", messageList[0].Text); Assert.Equal("Second message", messageList[1].Text); Assert.Equal("Third message", messageList[2].Text); Assert.Equal("System context message", messageList[3].Text); Assert.Equal("Response message", messageList[4].Text); } #endregion #region InvokingAsync Tests [Fact] [Trait("Category", "CosmosDB")] public async Task InvokingAsync_WithNoMessages_ShouldReturnEmptyAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(Guid.NewGuid().ToString())); // Act var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var messages = await provider.InvokingAsync(invokingContext); // Assert Assert.Empty(messages); } [Fact] [Trait("Category", "CosmosDB")] public async Task InvokingAsync_WithConversationIsolation_ShouldOnlyReturnMessagesForConversationAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); var conversation1 = Guid.NewGuid().ToString(); var conversation2 = Guid.NewGuid().ToString(); // Use different stateKey values so the providers don't overwrite each other's state in the shared session using var store1 = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(conversation1), stateKey: "conv1"); using var store2 = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(conversation2), stateKey: "conv2"); var context1 = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, [new ChatMessage(ChatRole.User, "Message for conversation 1")], []); var context2 = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, [new ChatMessage(ChatRole.User, "Message for conversation 2")], []); await store1.InvokedAsync(context1); await store2.InvokedAsync(context2); // Act var invokingContext1 = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var invokingContext2 = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var messages1 = await store1.InvokingAsync(invokingContext1); var messages2 = await store2.InvokingAsync(invokingContext2); // Assert var messageList1 = messages1.ToList(); var messageList2 = messages2.ToList(); Assert.Single(messageList1); Assert.Single(messageList2); Assert.Equal("Message for conversation 1", messageList1[0].Text); Assert.Equal("Message for conversation 2", messageList2[0].Text); Assert.Equal(AgentRequestMessageSourceType.ChatHistory, messageList1[0].GetAgentRequestMessageSourceType()); Assert.Equal(AgentRequestMessageSourceType.ChatHistory, messageList2[0].GetAgentRequestMessageSourceType()); } #endregion #region Integration Tests [Fact] [Trait("Category", "CosmosDB")] public async Task FullWorkflow_AddAndGet_ShouldWorkCorrectlyAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); var conversationId = $"test-conversation-{Guid.NewGuid():N}"; // Use unique conversation ID using var originalStore = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(conversationId)); var messages = new[] { new ChatMessage(ChatRole.System, "You are a helpful assistant."), new ChatMessage(ChatRole.User, "Hello!"), new ChatMessage(ChatRole.Assistant, "Hi there! How can I help you today?"), new ChatMessage(ChatRole.User, "What's the weather like?"), new ChatMessage(ChatRole.Assistant, "I'm sorry, I don't have access to current weather data.") }; // Act 1: Add messages var invokedContext = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, messages, []); await originalStore.InvokedAsync(invokedContext); // Act 2: Verify messages were added var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var retrievedMessages = await originalStore.InvokingAsync(invokingContext); var retrievedList = retrievedMessages.ToList(); Assert.Equal(5, retrievedList.Count); // Act 3: Create new provider instance for same conversation (test persistence) using var newProvider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(conversationId)); var newSession = CreateMockSession(); var newInvokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, newSession, []); var persistedMessages = await newProvider.InvokingAsync(newInvokingContext); var persistedList = persistedMessages.ToList(); // Assert final state Assert.Equal(5, persistedList.Count); Assert.Equal("You are a helpful assistant.", persistedList[0].Text); Assert.Equal("Hello!", persistedList[1].Text); Assert.Equal("Hi there! How can I help you today?", persistedList[2].Text); Assert.Equal("What's the weather like?", persistedList[3].Text); Assert.Equal("I'm sorry, I don't have access to current weather data.", persistedList[4].Text); } #endregion #region Disposal Tests [Fact] [Trait("Category", "CosmosDB")] public void Dispose_AfterUse_ShouldNotThrow() { // Arrange this.SkipIfEmulatorNotAvailable(); var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(Guid.NewGuid().ToString())); // Act & Assert provider.Dispose(); // Should not throw } [Fact] [Trait("Category", "CosmosDB")] public void Dispose_MultipleCalls_ShouldNotThrow() { // Arrange this.SkipIfEmulatorNotAvailable(); var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(Guid.NewGuid().ToString())); // Act & Assert provider.Dispose(); // First call provider.Dispose(); // Second call - should not throw } #endregion #region Hierarchical Partitioning Tests [Fact] [Trait("Category", "CosmosDB")] public void Constructor_WithHierarchicalConnectionString_ShouldCreateInstance() { // Arrange & Act this.SkipIfEmulatorNotAvailable(); // Act using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, _ => new CosmosChatHistoryProvider.State("session-789", "tenant-123", "user-456")); // Assert Assert.NotNull(provider); Assert.Equal(s_testDatabaseId, provider.DatabaseId); Assert.Equal(HierarchicalTestContainerId, provider.ContainerId); } [Fact] [Trait("Category", "CosmosDB")] public void Constructor_WithHierarchicalEndpoint_ShouldCreateInstance() { // Arrange & Act this.SkipIfEmulatorNotAvailable(); // Act TokenCredential credential = new DefaultAzureCredential(); using var provider = new CosmosChatHistoryProvider(s_emulatorEndpoint, credential, s_testDatabaseId, HierarchicalTestContainerId, _ => new CosmosChatHistoryProvider.State("session-789", "tenant-123", "user-456")); // Assert Assert.NotNull(provider); Assert.Equal(s_testDatabaseId, provider.DatabaseId); Assert.Equal(HierarchicalTestContainerId, provider.ContainerId); } [Fact] [Trait("Category", "CosmosDB")] public void Constructor_WithHierarchicalCosmosClient_ShouldCreateInstance() { // Arrange & Act this.SkipIfEmulatorNotAvailable(); using var cosmosClient = new CosmosClient(s_emulatorEndpoint, s_emulatorKey); using var provider = new CosmosChatHistoryProvider(cosmosClient, s_testDatabaseId, HierarchicalTestContainerId, _ => new CosmosChatHistoryProvider.State("session-789", "tenant-123", "user-456")); // Assert Assert.NotNull(provider); Assert.Equal(s_testDatabaseId, provider.DatabaseId); Assert.Equal(HierarchicalTestContainerId, provider.ContainerId); } [Fact] [Trait("Category", "CosmosDB")] public void State_WithEmptyConversationId_ShouldThrowArgumentException() { // Arrange & Act & Assert Assert.Throws(() => new CosmosChatHistoryProvider.State("")); } [Fact] [Trait("Category", "CosmosDB")] public void State_WithWhitespaceConversationId_ShouldThrowArgumentException() { // Arrange & Act & Assert Assert.Throws(() => new CosmosChatHistoryProvider.State(" ")); } [Fact] [Trait("Category", "CosmosDB")] public async Task InvokedAsync_WithHierarchicalPartitioning_ShouldAddMessageWithMetadataAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); const string TenantId = "tenant-123"; const string UserId = "user-456"; const string SessionId = "session-789"; // Test hierarchical partitioning constructor with connection string using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, _ => new CosmosChatHistoryProvider.State(SessionId, TenantId, UserId)); var message = new ChatMessage(ChatRole.User, "Hello from hierarchical partitioning!"); var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, [message], []); // Act await provider.InvokedAsync(context); // Wait a moment for eventual consistency await Task.Delay(100); // Assert var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var messages = await provider.InvokingAsync(invokingContext); var messageList = messages.ToList(); Assert.Single(messageList); Assert.Equal("Hello from hierarchical partitioning!", messageList[0].Text); Assert.Equal(ChatRole.User, messageList[0].Role); // Verify that the document is stored with hierarchical partitioning metadata var directQuery = new QueryDefinition("SELECT * FROM c WHERE c.conversationId = @conversationId AND c.type = @type") .WithParameter("@conversationId", SessionId) .WithParameter("@type", "ChatMessage"); var iterator = this._setupClient!.GetDatabase(s_testDatabaseId).GetContainer(HierarchicalTestContainerId) .GetItemQueryIterator(directQuery, requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKeyBuilder().Add(TenantId).Add(UserId).Add(SessionId).Build() }); var response = await iterator.ReadNextAsync(); var document = response.FirstOrDefault(); Assert.NotNull(document); // The document should have hierarchical metadata Assert.Equal(SessionId, (string)document!.conversationId); Assert.Equal(TenantId, (string)document!.tenantId); Assert.Equal(UserId, (string)document!.userId); Assert.Equal(SessionId, (string)document!.sessionId); } [Fact] [Trait("Category", "CosmosDB")] public async Task InvokedAsync_WithHierarchicalMultipleMessages_ShouldAddAllMessagesAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); const string TenantId = "tenant-batch"; const string UserId = "user-batch"; const string SessionId = "session-batch"; // Test hierarchical partitioning constructor with connection string using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, _ => new CosmosChatHistoryProvider.State(SessionId, TenantId, UserId)); var messages = new[] { new ChatMessage(ChatRole.User, "First hierarchical message"), new ChatMessage(ChatRole.Assistant, "Second hierarchical message"), new ChatMessage(ChatRole.User, "Third hierarchical message") }; var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, messages, []); // Act await provider.InvokedAsync(context); // Wait a moment for eventual consistency await Task.Delay(100); // Assert var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var retrievedMessages = await provider.InvokingAsync(invokingContext); var messageList = retrievedMessages.ToList(); Assert.Equal(3, messageList.Count); Assert.Equal("First hierarchical message", messageList[0].Text); Assert.Equal("Second hierarchical message", messageList[1].Text); Assert.Equal("Third hierarchical message", messageList[2].Text); } [Fact] [Trait("Category", "CosmosDB")] public async Task InvokingAsync_WithHierarchicalPartitionIsolation_ShouldIsolateMessagesByUserIdAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); const string TenantId = "tenant-isolation"; const string UserId1 = "user-1"; const string UserId2 = "user-2"; const string SessionId = "session-isolation"; // Different userIds create different hierarchical partitions, providing proper isolation // Use different stateKey values so the providers don't overwrite each other's state in the shared session using var store1 = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, _ => new CosmosChatHistoryProvider.State(SessionId, TenantId, UserId1), stateKey: "user1"); using var store2 = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, _ => new CosmosChatHistoryProvider.State(SessionId, TenantId, UserId2), stateKey: "user2"); // Add messages to both stores var context1 = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, [new ChatMessage(ChatRole.User, "Message from user 1")], []); var context2 = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, [new ChatMessage(ChatRole.User, "Message from user 2")], []); await store1.InvokedAsync(context1); await store2.InvokedAsync(context2); // Wait a moment for eventual consistency await Task.Delay(100); // Act & Assert var invokingContext1 = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var invokingContext2 = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var messages1 = await store1.InvokingAsync(invokingContext1); var messageList1 = messages1.ToList(); var messages2 = await store2.InvokingAsync(invokingContext2); var messageList2 = messages2.ToList(); // With true hierarchical partitioning, each user sees only their own messages Assert.Single(messageList1); Assert.Single(messageList2); Assert.Equal("Message from user 1", messageList1[0].Text); Assert.Equal("Message from user 2", messageList2[0].Text); } [Fact] [Trait("Category", "CosmosDB")] public async Task StateBag_WithHierarchicalPartitioning_ShouldPreserveStateAcrossProviderInstancesAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); const string TenantId = "tenant-serialize"; const string UserId = "user-serialize"; const string SessionId = "session-serialize"; using var originalStore = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, _ => new CosmosChatHistoryProvider.State(SessionId, TenantId, UserId)); var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, [new ChatMessage(ChatRole.User, "Test serialization message")], []); await originalStore.InvokedAsync(context); // Wait a moment for eventual consistency await Task.Delay(100); // Act - Create a new provider that uses a different intializer, but we will use the same session. using var newStore = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, _ => new CosmosChatHistoryProvider.State(Guid.NewGuid().ToString())); // Assert - The new provider should read the same messages from Cosmos DB var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var messages = await newStore.InvokingAsync(invokingContext); var messageList = messages.ToList(); Assert.Single(messageList); Assert.Equal("Test serialization message", messageList[0].Text); Assert.Equal(s_testDatabaseId, newStore.DatabaseId); Assert.Equal(HierarchicalTestContainerId, newStore.ContainerId); } [Fact] [Trait("Category", "CosmosDB")] public async Task HierarchicalAndSimplePartitioning_ShouldCoexistAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); const string SessionId = "coexist-session"; var session = CreateMockSession(); // Create simple provider using simple partitioning container and hierarchical provider using hierarchical container // Use different stateKey values so the providers don't overwrite each other's state in the shared session using var simpleProvider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(SessionId), stateKey: "simple"); using var hierarchicalProvider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, _ => new CosmosChatHistoryProvider.State(SessionId, "tenant-coexist", "user-coexist"), stateKey: "hierarchical"); // Add messages to both var simpleContext = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, [new ChatMessage(ChatRole.User, "Simple partitioning message")], []); var hierarchicalContext = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, [new ChatMessage(ChatRole.User, "Hierarchical partitioning message")], []); await simpleProvider.InvokedAsync(simpleContext); await hierarchicalProvider.InvokedAsync(hierarchicalContext); // Wait a moment for eventual consistency await Task.Delay(100); // Act & Assert var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var simpleMessages = await simpleProvider.InvokingAsync(invokingContext); var simpleMessageList = simpleMessages.ToList(); var hierarchicalMessages = await hierarchicalProvider.InvokingAsync(invokingContext); var hierarchicalMessageList = hierarchicalMessages.ToList(); // Each should only see its own messages since they use different containers Assert.Single(simpleMessageList); Assert.Single(hierarchicalMessageList); Assert.Equal("Simple partitioning message", simpleMessageList[0].Text); Assert.Equal("Hierarchical partitioning message", hierarchicalMessageList[0].Text); } [Fact] [Trait("Category", "CosmosDB")] public async Task MaxMessagesToRetrieve_ShouldLimitAndReturnMostRecentAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); const string ConversationId = "max-messages-test"; using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(ConversationId)); // Add 10 messages var messages = new List(); for (int i = 1; i <= 10; i++) { messages.Add(new ChatMessage(ChatRole.User, $"Message {i}")); await Task.Delay(10); // Small delay to ensure different timestamps } var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, messages, []); await provider.InvokedAsync(context); // Wait for eventual consistency await Task.Delay(100); // Act - Set max to 5 and retrieve provider.MaxMessagesToRetrieve = 5; var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var retrievedMessages = await provider.InvokingAsync(invokingContext); var messageList = retrievedMessages.ToList(); // Assert - Should get the 5 most recent messages (6-10) in ascending order Assert.Equal(5, messageList.Count); Assert.Equal("Message 6", messageList[0].Text); Assert.Equal("Message 7", messageList[1].Text); Assert.Equal("Message 8", messageList[2].Text); Assert.Equal("Message 9", messageList[3].Text); Assert.Equal("Message 10", messageList[4].Text); } [Fact] [Trait("Category", "CosmosDB")] public async Task MaxMessagesToRetrieve_Null_ShouldReturnAllMessagesAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); const string ConversationId = "max-messages-null-test"; using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(ConversationId)); // Add 10 messages var messages = new List(); for (int i = 1; i <= 10; i++) { messages.Add(new ChatMessage(ChatRole.User, $"Message {i}")); } var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, messages, []); await provider.InvokedAsync(context); // Wait for eventual consistency await Task.Delay(100); // Act - No limit set (default null) var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var retrievedMessages = await provider.InvokingAsync(invokingContext); var messageList = retrievedMessages.ToList(); // Assert - Should get all 10 messages Assert.Equal(10, messageList.Count); Assert.Equal("Message 1", messageList[0].Text); Assert.Equal("Message 10", messageList[9].Text); } [Fact] [Trait("Category", "CosmosDB")] public async Task GetMessageCountAsync_WithMessages_ShouldReturnCorrectCountAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); const string ConversationId = "count-test-conversation"; using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(ConversationId)); // Add 5 messages var messages = new List(); for (int i = 1; i <= 5; i++) { messages.Add(new ChatMessage(ChatRole.User, $"Message {i}")); } var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, messages, []); await provider.InvokedAsync(context); // Wait for eventual consistency await Task.Delay(100); // Act var count = await provider.GetMessageCountAsync(session); // Assert Assert.Equal(5, count); } [Fact] [Trait("Category", "CosmosDB")] public async Task GetMessageCountAsync_WithNoMessages_ShouldReturnZeroAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); const string ConversationId = "empty-count-test-conversation"; using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(ConversationId)); // Act var count = await provider.GetMessageCountAsync(session); // Assert Assert.Equal(0, count); } [Fact] [Trait("Category", "CosmosDB")] public async Task ClearMessagesAsync_WithMessages_ShouldDeleteAndReturnCountAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); const string ConversationId = "clear-test-conversation"; using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(ConversationId)); // Add 3 messages var messages = new List { new(ChatRole.User, "Message 1"), new(ChatRole.Assistant, "Message 2"), new(ChatRole.User, "Message 3") }; var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, messages, []); await provider.InvokedAsync(context); // Wait for eventual consistency await Task.Delay(100); // Verify messages exist var countBefore = await provider.GetMessageCountAsync(session); Assert.Equal(3, countBefore); // Act var deletedCount = await provider.ClearMessagesAsync(session); // Wait for eventual consistency await Task.Delay(100); // Assert Assert.Equal(3, deletedCount); // Verify messages are deleted var countAfter = await provider.GetMessageCountAsync(session); Assert.Equal(0, countAfter); var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var retrievedMessages = await provider.InvokingAsync(invokingContext); Assert.Empty(retrievedMessages); } [Fact] [Trait("Category", "CosmosDB")] public async Task ClearMessagesAsync_WithNoMessages_ShouldReturnZeroAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); const string ConversationId = "empty-clear-test-conversation"; using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(ConversationId)); // Act var deletedCount = await provider.ClearMessagesAsync(session); // Assert Assert.Equal(0, deletedCount); } #endregion #region Message Filter Tests [Fact] [Trait("Category", "CosmosDB")] public async Task InvokedAsync_DefaultFilter_ExcludesChatHistoryMessagesFromStorageAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); var conversationId = Guid.NewGuid().ToString(); using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(conversationId)); var requestMessages = new[] { new ChatMessage(ChatRole.User, "External message"), new ChatMessage(ChatRole.System, "From history") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "HistorySource") } } }, new ChatMessage(ChatRole.System, "From context provider") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "ContextSource") } } }, }; var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, requestMessages, [new ChatMessage(ChatRole.Assistant, "Response")]); // Act await provider.InvokedAsync(context); // Wait for eventual consistency await Task.Delay(100); // Assert - ChatHistory message excluded, External + AIContextProvider + Response stored var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var messages = (await provider.InvokingAsync(invokingContext)).ToList(); Assert.Equal(3, messages.Count); Assert.Equal("External message", messages[0].Text); Assert.Equal("From context provider", messages[1].Text); Assert.Equal("Response", messages[2].Text); } [Fact] [Trait("Category", "CosmosDB")] public async Task InvokedAsync_CustomStorageInputFilter_OverridesDefaultAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); var conversationId = Guid.NewGuid().ToString(); using var provider = new CosmosChatHistoryProvider( this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(conversationId), storeInputRequestMessageFilter: messages => messages.Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External)); var requestMessages = new[] { new ChatMessage(ChatRole.User, "External message"), new ChatMessage(ChatRole.System, "From history") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "HistorySource") } } }, new ChatMessage(ChatRole.System, "From context provider") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "ContextSource") } } }, }; var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, requestMessages, [new ChatMessage(ChatRole.Assistant, "Response")]); // Act await provider.InvokedAsync(context); // Wait for eventual consistency await Task.Delay(100); // Assert - Custom filter: only External + Response stored (both ChatHistory and AIContextProvider excluded) var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var messages = (await provider.InvokingAsync(invokingContext)).ToList(); Assert.Equal(2, messages.Count); Assert.Equal("External message", messages[0].Text); Assert.Equal("Response", messages[1].Text); } [Fact] [Trait("Category", "CosmosDB")] public async Task InvokingAsync_RetrievalOutputFilter_FiltersRetrievedMessagesAsync() { // Arrange this.SkipIfEmulatorNotAvailable(); var session = CreateMockSession(); var conversationId = Guid.NewGuid().ToString(); using var provider = new CosmosChatHistoryProvider( this._connectionString, s_testDatabaseId, TestContainerId, _ => new CosmosChatHistoryProvider.State(conversationId), provideOutputMessageFilter: messages => messages.Where(m => m.Role == ChatRole.User)); var requestMessages = new[] { new ChatMessage(ChatRole.User, "User message"), new ChatMessage(ChatRole.System, "System message"), }; var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, requestMessages, [new ChatMessage(ChatRole.Assistant, "Assistant response")]); await provider.InvokedAsync(context); // Wait for eventual consistency await Task.Delay(100); // Act var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []); var messages = (await provider.InvokingAsync(invokingContext)).ToList(); // Assert - Only User messages returned (System and Assistant filtered by ProvideOutputMessageFilter) Assert.Single(messages); Assert.Equal("User message", messages[0].Text); Assert.Equal(ChatRole.User, messages[0].Role); } #endregion }