// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Moq; namespace Microsoft.Agents.AI.UnitTests.Data; /// /// Contains unit tests for . /// public sealed class TextSearchProviderTests { private static readonly AIAgent s_mockAgent = new Mock().Object; private readonly Mock> _loggerMock; private readonly Mock _loggerFactoryMock; public TextSearchProviderTests() { this._loggerMock = new(); this._loggerFactoryMock = new(); this._loggerFactoryMock .Setup(f => f.CreateLogger(It.IsAny())) .Returns(this._loggerMock.Object); this._loggerFactoryMock .Setup(f => f.CreateLogger(typeof(TextSearchProvider).FullName!)) .Returns(this._loggerMock.Object); this._loggerMock .Setup(f => f.IsEnabled(It.IsAny())) .Returns(true); } [Fact] public void StateKeys_ReturnsDefaultKey_WhenNoOptionsProvided() { // Arrange & Act var provider = new TextSearchProvider((_, _) => Task.FromResult>([])); // Assert Assert.Single(provider.StateKeys); Assert.Contains("TextSearchProvider", provider.StateKeys); } [Fact] public void StateKeys_ReturnsCustomKey_WhenSetViaOptions() { // Arrange & Act var provider = new TextSearchProvider( (_, _) => Task.FromResult>([]), new TextSearchProviderOptions { StateKey = "custom-key" }); // Assert Assert.Single(provider.StateKeys); Assert.Contains("custom-key", provider.StateKeys); } [Theory] [InlineData(null, null, true)] [InlineData("Custom context prompt", "Custom citations prompt", false)] public async Task InvokingAsync_ShouldInjectFormattedResultsAsync(string? overrideContextPrompt, string? overrideCitationsPrompt, bool withLogging) { // Arrange List results = [ new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" }, new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" } ]; string? capturedInput = null; Task> SearchDelegateAsync(string input, CancellationToken ct) { capturedInput = input; return Task.FromResult>(results); } var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, ContextPrompt = overrideContextPrompt, CitationsPrompt = overrideCitationsPrompt, EnableSensitiveTelemetryData = true }; var provider = new TextSearchProvider(SearchDelegateAsync, options, withLogging ? this._loggerFactoryMock.Object : null); var invokingContext = new AIContextProvider.InvokingContext( s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List { new(ChatRole.User, "Sample user question?"), new(ChatRole.User, "Additional part") } }); // Act var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None); // Assert Assert.Equal("Sample user question?\nAdditional part", capturedInput); Assert.Null(aiContext.Instructions); // TextSearchProvider uses a user message for context injection. Assert.NotNull(aiContext.Messages); var messages = aiContext.Messages!.ToList(); Assert.Equal(3, messages.Count); // 2 input messages + 1 search result message Assert.Equal("Sample user question?", messages[0].Text); Assert.Equal("Additional part", messages[1].Text); Assert.Equal(AgentRequestMessageSourceType.External, messages[0].GetAgentRequestMessageSourceType()); Assert.Equal(AgentRequestMessageSourceType.External, messages[1].GetAgentRequestMessageSourceType()); var message = messages.Last(); Assert.Equal(ChatRole.User, message.Role); Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, message.GetAgentRequestMessageSourceType()); string text = message.Text!; if (overrideContextPrompt is null) { Assert.Contains("## Additional Context", text); Assert.Contains("Consider the following information from source documents when responding to the user:", text); } else { Assert.Contains(overrideContextPrompt, text); } Assert.Contains("SourceDocName: Doc1", text); Assert.Contains("SourceDocLink: http://example.com/doc1", text); Assert.Contains("Contents: Content of Doc1", text); Assert.Contains("SourceDocName: Doc2", text); Assert.Contains("SourceDocLink: http://example.com/doc2", text); Assert.Contains("Contents: Content of Doc2", text); if (overrideCitationsPrompt is null) { Assert.Contains("Include citations to the source document with document name and link if document name and link is available.", text); } else { Assert.Contains(overrideCitationsPrompt, text); } if (withLogging) { this._loggerMock.Verify( l => l.Log( LogLevel.Information, It.IsAny(), It.Is((v, t) => v.ToString()!.Contains("TextSearchProvider: Retrieved 2 search results.")), It.IsAny(), It.IsAny>()), Times.AtLeastOnce); this._loggerMock.Verify( l => l.Log( LogLevel.Trace, It.IsAny(), It.Is((v, t) => v.ToString()!.Contains("TextSearchProvider: Search Results\nInput:Sample user question?\nAdditional part\nOutput")), It.IsAny(), It.IsAny>()), Times.AtLeastOnce); } } [Theory] [InlineData(false, false)] [InlineData(false, true)] [InlineData(true, false)] [InlineData(true, true)] public async Task InvokingAsync_RedactsLogDataBasedOnOptionsAsync(bool enableSensitiveTelemetryData, bool useCustomRedactor) { // Arrange List results = [ new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" } ]; Task> SearchDelegateAsync(string input, CancellationToken ct) { return Task.FromResult>(results); } var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, EnableSensitiveTelemetryData = enableSensitiveTelemetryData, Redactor = useCustomRedactor ? new ReplacingRedactor("***") : null }; var provider = new TextSearchProvider(SearchDelegateAsync, options, this._loggerFactoryMock.Object); var invokingContext = new AIContextProvider.InvokingContext( s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List { new(ChatRole.User, "Sample user question?") } }); // Act await provider.InvokingAsync(invokingContext, CancellationToken.None); // Assert — EnableSensitiveTelemetryData takes precedence over Redactor var traceInvocation = this._loggerMock.Invocations .Where(i => i.Method.Name == nameof(ILogger.Log)) .FirstOrDefault(i => (LogLevel)i.Arguments[0]! == LogLevel.Trace); Assert.NotNull(traceInvocation); var state = Assert.IsType>>(traceInvocation.Arguments[2], exactMatch: false); var inputValue = state.First(kvp => kvp.Key == "Input").Value; var messageTextValue = state.First(kvp => kvp.Key == "MessageText").Value; if (enableSensitiveTelemetryData) { // EnableSensitiveTelemetryData=true: raw data passes through regardless of Redactor Assert.Equal("Sample user question?", inputValue); Assert.Contains("Content of Doc1", messageTextValue?.ToString()!); } else { // EnableSensitiveTelemetryData=false: custom redactor or default placeholder string expectedRedaction = useCustomRedactor ? "***" : ""; Assert.Equal(expectedRedaction, inputValue); Assert.Equal(expectedRedaction, messageTextValue); } } [Theory] [InlineData(null, null, "Search", "Allows searching for additional information to help answer the user question.")] [InlineData("CustomSearch", "CustomDescription", "CustomSearch", "CustomDescription")] public async Task InvokingAsync_OnDemand_ShouldExposeSearchToolAsync(string? overrideName, string? overrideDescription, string expectedName, string expectedDescription) { // Arrange var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.OnDemandFunctionCalling, FunctionToolName = overrideName, FunctionToolDescription = overrideDescription }; var provider = new TextSearchProvider(this.NoResultSearchAsync, options); var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List { new(ChatRole.User, "Q?") } }); // Act var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None); // Assert Assert.NotNull(aiContext.Messages); // Input messages are preserved. var messages = aiContext.Messages!.ToList(); Assert.Single(messages); Assert.Equal("Q?", messages[0].Text); Assert.NotNull(aiContext.Tools); var tools = aiContext.Tools!.ToList(); Assert.Single(tools); var tool = tools[0]; Assert.Equal(expectedName, tool.Name); Assert.Equal(expectedDescription, tool.Description); } [Fact] public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync() { // Arrange var provider = new TextSearchProvider(this.FailingSearchAsync, loggerFactory: this._loggerFactoryMock.Object); var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List { new(ChatRole.User, "Q?") } }); // Act var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None); // Assert Assert.NotNull(aiContext.Messages); // Input messages are preserved on error. var messages = aiContext.Messages!.ToList(); Assert.Single(messages); Assert.Equal("Q?", messages[0].Text); Assert.Null(aiContext.Tools); this._loggerMock.Verify( l => l.Log( LogLevel.Error, It.IsAny(), It.Is((v, t) => v.ToString()!.Contains("TextSearchProvider: Failed to search for data due to error")), It.IsAny(), It.IsAny>()), Times.AtLeastOnce); } [Theory] [InlineData(null, null)] [InlineData("Custom context prompt", "Custom citations prompt")] public async Task SearchAsync_ShouldReturnFormattedResultsAsync(string? overrideContextPrompt, string? overrideCitationsPrompt) { // Arrange List results = [ new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" }, new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" } ]; Task> SearchDelegateAsync(string input, CancellationToken ct) { return Task.FromResult>(results); } var options = new TextSearchProviderOptions { ContextPrompt = overrideContextPrompt, CitationsPrompt = overrideCitationsPrompt }; var provider = new TextSearchProvider(SearchDelegateAsync, options); // Act var formatted = await provider.SearchAsync("Sample user question?", CancellationToken.None); // Assert if (overrideContextPrompt is null) { Assert.Contains("## Additional Context", formatted); Assert.Contains("Consider the following information from source documents when responding to the user:", formatted); } else { Assert.Contains(overrideContextPrompt, formatted); } Assert.Contains("SourceDocName: Doc1", formatted); Assert.Contains("SourceDocLink: http://example.com/doc1", formatted); Assert.Contains("Contents: Content of Doc1", formatted); Assert.Contains("SourceDocName: Doc2", formatted); Assert.Contains("SourceDocLink: http://example.com/doc2", formatted); Assert.Contains("Contents: Content of Doc2", formatted); if (overrideCitationsPrompt is null) { Assert.Contains("Include citations to the source document with document name and link if document name and link is available.", formatted); } else { Assert.Contains(overrideCitationsPrompt, formatted); } } [Fact] public async Task InvokingAsync_ShouldUseContextFormatterWhenProvidedAsync() { // Arrange List results = [ new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" }, new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" } ]; Task> SearchDelegateAsync(string input, CancellationToken ct) { return Task.FromResult>(results); } var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, ContextFormatter = r => $"Custom formatted context with {r.Count} results." }; var provider = new TextSearchProvider(SearchDelegateAsync, options); var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List { new(ChatRole.User, "Q?") } }); // Act var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None); // Assert Assert.NotNull(aiContext.Messages); var messages = aiContext.Messages!.ToList(); Assert.Equal(2, messages.Count); // 1 input message + 1 formatted result message Assert.Equal("Q?", messages[0].Text); Assert.Equal("Custom formatted context with 2 results.", messages[1].Text); } [Fact] public async Task InvokingAsync_WithRawRepresentations_ContextFormatterCanAccessAsync() { // Arrange var payload1 = new RawPayload { Id = "R1" }; var payload2 = new RawPayload { Id = "R2" }; List results = [ new() { SourceName = "Doc1", Text = "Content 1", RawRepresentation = payload1 }, new() { SourceName = "Doc2", Text = "Content 2", RawRepresentation = payload2 } ]; Task> SearchDelegateAsync(string input, CancellationToken ct) { return Task.FromResult>(results); } var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, ContextFormatter = r => string.Join(",", r.Select(x => ((RawPayload)x.RawRepresentation!).Id)) }; var provider = new TextSearchProvider(SearchDelegateAsync, options); var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List { new(ChatRole.User, "Q?") } }); // Act var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None); // Assert Assert.NotNull(aiContext.Messages); var messages = aiContext.Messages!.ToList(); Assert.Equal(2, messages.Count); // 1 input message + 1 formatted result message Assert.Equal("Q?", messages[0].Text); Assert.Equal("R1,R2", messages[1].Text); } [Fact] public async Task InvokingAsync_WithNoResults_ShouldReturnEmptyContextAsync() { // Arrange var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke }; var provider = new TextSearchProvider(this.NoResultSearchAsync, options); var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List { new(ChatRole.User, "Q?") } }); // Act var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None); // Assert Assert.NotNull(aiContext.Messages); // Input messages are preserved when no results found. var messages = aiContext.Messages!.ToList(); Assert.Single(messages); Assert.Equal("Q?", messages[0].Text); Assert.Null(aiContext.Instructions); Assert.Null(aiContext.Tools); } #region Message Filter Tests [Fact] public async Task InvokingAsync_DefaultFilter_ExcludesNonExternalMessagesFromSearchInputAsync() { // Arrange string? capturedInput = null; Task> SearchDelegateAsync(string input, CancellationToken ct) { capturedInput = input; return Task.FromResult>([]); } var provider = new TextSearchProvider(SearchDelegateAsync); var requestMessages = new List { new(ChatRole.User, "External message"), new(ChatRole.System, "From history") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "HistorySource") } } }, new(ChatRole.System, "From context provider") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "ContextSource") } } }, }; var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = requestMessages }); // Act await provider.InvokingAsync(invokingContext, CancellationToken.None); // Assert - Only external messages should be used for search input Assert.Equal("External message", capturedInput); } [Fact] public async Task InvokingAsync_CustomSearchInputFilter_OverridesDefaultAsync() { // Arrange string? capturedInput = null; Task> SearchDelegateAsync(string input, CancellationToken ct) { capturedInput = input; return Task.FromResult>([]); } var provider = new TextSearchProvider(SearchDelegateAsync, new TextSearchProviderOptions { SearchInputMessageFilter = messages => messages.Where(m => m.Role == ChatRole.System) }); var requestMessages = new List { new(ChatRole.User, "User message"), new(ChatRole.System, "System message"), }; var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), new AIContext { Messages = requestMessages }); // Act await provider.InvokingAsync(invokingContext, CancellationToken.None); // Assert - Custom filter keeps only System messages Assert.Equal("System message", capturedInput); } [Fact] public async Task InvokedAsync_DefaultFilter_ExcludesNonExternalMessagesFromStorageAsync() { // Arrange var options = new TextSearchProviderOptions { RecentMessageMemoryLimit = 10, RecentMessageRolesIncluded = [ChatRole.User, ChatRole.System] }; string? capturedInput = null; Task> SearchDelegateAsync(string input, CancellationToken ct) { capturedInput = input; return Task.FromResult>([]); } var provider = new TextSearchProvider(SearchDelegateAsync, options); var session = new TestAgentSession(); var requestMessages = new List { new(ChatRole.User, "External message"), new(ChatRole.System, "From history") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "HistorySource") } } }, new(ChatRole.System, "From context provider") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "ContextSource") } } }, }; // Store messages via InvokedAsync await provider.InvokedAsync(new(s_mockAgent, session, requestMessages, [])); // Now invoke to read stored memory var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, session, new AIContext { Messages = [new ChatMessage(ChatRole.User, "Next")] }); await provider.InvokingAsync(invokingContext, CancellationToken.None); // Assert - Only "External message" was stored in memory, so search input = "External message" + "Next" Assert.Equal("External message\nNext", capturedInput); } [Fact] public async Task InvokedAsync_CustomStorageInputFilter_OverridesDefaultAsync() { // Arrange var options = new TextSearchProviderOptions { RecentMessageMemoryLimit = 10, RecentMessageRolesIncluded = [ChatRole.User, ChatRole.System], StorageInputRequestMessageFilter = messages => messages // No filtering - store everything }; string? capturedInput = null; Task> SearchDelegateAsync(string input, CancellationToken ct) { capturedInput = input; return Task.FromResult>([]); } var provider = new TextSearchProvider(SearchDelegateAsync, options); var session = new TestAgentSession(); var requestMessages = new List { new(ChatRole.User, "External message"), new(ChatRole.System, "From history") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "HistorySource") } } }, }; // Store messages via InvokedAsync await provider.InvokedAsync(new(s_mockAgent, session, requestMessages, [])); // Now invoke to read stored memory var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, session, new AIContext { Messages = [new ChatMessage(ChatRole.User, "Next")] }); await provider.InvokingAsync(invokingContext, CancellationToken.None); // Assert - Both messages stored (identity filter), so search input includes all + current Assert.Equal("External message\nFrom history\nNext", capturedInput); } #endregion #region Recent Message Memory Tests [Fact] public async Task InvokingAsync_WithPreviousFailedRequest_ShouldNotIncludeFailedRequestInputInSearchInputAsync() { // Arrange var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, RecentMessageMemoryLimit = 3 }; string? capturedInput = null; Task> SearchDelegateAsync(string input, CancellationToken ct) { capturedInput = input; return Task.FromResult>([]); // No results needed. } var provider = new TextSearchProvider(SearchDelegateAsync, options); // Populate memory with more messages than the limit (A,B,C,D) -> should retain B,C,D var initialMessages = new[] { new ChatMessage(ChatRole.User, "A"), new ChatMessage(ChatRole.Assistant, "B"), new ChatMessage(ChatRole.User, "C"), new ChatMessage(ChatRole.Assistant, "D"), }; var session = new TestAgentSession(); await provider.InvokedAsync(new(s_mockAgent, session, initialMessages, new InvalidOperationException("Request Failed"))); var invokingContext = new AIContextProvider.InvokingContext( s_mockAgent, session, new AIContext { Messages = new List { new(ChatRole.User, "E") } }); // Act await provider.InvokingAsync(invokingContext, CancellationToken.None); // Assert Assert.Equal("E", capturedInput); // Only the messages from the current request, since previous failed request should not be stored. } [Fact] public async Task InvokingAsync_WithRecentMessageMemory_ShouldIncludeStoredMessagesInSearchInputAsync() { // Arrange var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, RecentMessageMemoryLimit = 3, RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant] }; string? capturedInput = null; Task> SearchDelegateAsync(string input, CancellationToken ct) { capturedInput = input; return Task.FromResult>([]); // No results needed. } var provider = new TextSearchProvider(SearchDelegateAsync, options); var session = new TestAgentSession(); // Populate memory with more messages than the limit (A,B,C,D) -> should retain B,C,D var initialMessages = new[] { new ChatMessage(ChatRole.User, "A"), new ChatMessage(ChatRole.Assistant, "B"), new ChatMessage(ChatRole.User, "C"), new ChatMessage(ChatRole.Assistant, "D"), }; await provider.InvokedAsync(new(s_mockAgent, session, initialMessages, [])); var invokingContext = new AIContextProvider.InvokingContext( s_mockAgent, session, new AIContext { Messages = new List { new(ChatRole.User, "E") } }); // Act await provider.InvokingAsync(invokingContext, CancellationToken.None); // Assert Assert.Equal("B\nC\nD\nE", capturedInput); // Memory first (truncated) then current request. } [Fact] public async Task InvokingAsync_WithAccumulatedMemoryAcrossInvocations_ShouldIncludeAllUpToLimitAsync() { // Arrange var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, RecentMessageMemoryLimit = 5, RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant] }; string? capturedInput = null; Task> SearchDelegateAsync(string input, CancellationToken ct) { capturedInput = input; return Task.FromResult>([]); } var provider = new TextSearchProvider(SearchDelegateAsync, options); var session = new TestAgentSession(); // First memory update (A,B) await provider.InvokedAsync(new( s_mockAgent, session, [ new ChatMessage(ChatRole.User, "A"), new ChatMessage(ChatRole.Assistant, "B"), ], [])); // Second memory update (C,D,E) await provider.InvokedAsync(new( s_mockAgent, session, [ new ChatMessage(ChatRole.User, "C"), new ChatMessage(ChatRole.Assistant, "D"), new ChatMessage(ChatRole.User, "E"), ], [])); var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, session, new AIContext { Messages = new List { new(ChatRole.User, "F") } }); // Act await provider.InvokingAsync(invokingContext, CancellationToken.None); // Assert Assert.Equal("A\nB\nC\nD\nE\nF", capturedInput); // All retained (limit 5) + current request message. } [Fact] public async Task InvokingAsync_WithRecentMessageRolesIncluded_ShouldFilterRolesAsync() { // Arrange var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, RecentMessageMemoryLimit = 4, RecentMessageRolesIncluded = [ChatRole.Assistant] // Only retain assistant messages. }; string? capturedInput = null; Task> SearchDelegateAsync(string input, CancellationToken ct) { capturedInput = input; return Task.FromResult>([]); // No results needed for this test. } var provider = new TextSearchProvider(SearchDelegateAsync, options); var session = new TestAgentSession(); // Populate memory with mixed roles; only Assistant messages (A1,A2) should be retained. var initialMessages = new[] { new ChatMessage(ChatRole.User, "U1"), new ChatMessage(ChatRole.Assistant, "A1"), new ChatMessage(ChatRole.User, "U2"), new ChatMessage(ChatRole.Assistant, "A2"), }; await provider.InvokedAsync(new(s_mockAgent, session, initialMessages, [])); var invokingContext = new AIContextProvider.InvokingContext( s_mockAgent, session, new AIContext { Messages = new List { new(ChatRole.User, "Question?") } }); // Current request message always appended. // Act await provider.InvokingAsync(invokingContext, CancellationToken.None); // Assert Assert.Equal("A1\nA2\nQuestion?", capturedInput); // Only assistant messages from memory + current request. } #endregion #region Serialization Tests [Fact] public async Task InvokedAsync_ShouldPersistMessagesToSessionStateBagAsync() { // Arrange var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, RecentMessageMemoryLimit = 3, RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant] }; var provider = new TextSearchProvider(this.NoResultSearchAsync, options); var session = new TestAgentSession(); var messages = new[] { new ChatMessage(ChatRole.User, "M1"), new ChatMessage(ChatRole.Assistant, "M2"), new ChatMessage(ChatRole.User, "M3"), }; // Act await provider.InvokedAsync(new(s_mockAgent, session, messages, [])); // Populate recent memory. // Assert - State should be in the session's StateBag var stateBagSerialized = session.StateBag.Serialize(); Assert.True(stateBagSerialized.TryGetProperty("TextSearchProvider", out var stateProperty)); Assert.True(stateProperty.TryGetProperty("recentMessagesText", out var recentProperty)); Assert.Equal(JsonValueKind.Array, recentProperty.ValueKind); var list = recentProperty.EnumerateArray().Select(e => e.GetString()).ToList(); Assert.Equal(3, list.Count); Assert.Equal(["M1", "M2", "M3"], list); } [Fact] public async Task StateBag_RoundtripRestoresMessagesAsync() { // Arrange var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, RecentMessageMemoryLimit = 4, RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant] }; var provider = new TextSearchProvider(this.NoResultSearchAsync, options); var session = new TestAgentSession(); var messages = new[] { new ChatMessage(ChatRole.User, "A"), new ChatMessage(ChatRole.Assistant, "B"), new ChatMessage(ChatRole.User, "C"), new ChatMessage(ChatRole.Assistant, "D"), }; await provider.InvokedAsync(new(s_mockAgent, session, messages, [])); // Act - Serialize and deserialize the StateBag var serializedStateBag = session.StateBag.Serialize(); var restoredSession = new TestAgentSession(AgentSessionStateBag.Deserialize(serializedStateBag)); string? capturedInput = null; Task> SearchDelegate2Async(string input, CancellationToken ct) { capturedInput = input; return Task.FromResult>([]); } var newProvider = new TextSearchProvider(SearchDelegate2Async, new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, RecentMessageMemoryLimit = 4 }); await newProvider.InvokingAsync(new AIContextProvider.InvokingContext(s_mockAgent, restoredSession, new AIContext()), CancellationToken.None); // Trigger search to read memory. // Assert Assert.NotNull(capturedInput); Assert.Equal("A\nB\nC\nD", capturedInput); } [Fact] public async Task InvokingAsync_WithEmptyStateBag_ShouldHaveNoMessagesAsync() { // Arrange var session = new TestAgentSession(); // Fresh session with empty StateBag string? capturedInput = null; Task> SearchDelegate2Async(string input, CancellationToken ct) { capturedInput = input; return Task.FromResult>([]); } // Act var provider = new TextSearchProvider(SearchDelegate2Async, new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, RecentMessageMemoryLimit = 3 }); await provider.InvokingAsync(new AIContextProvider.InvokingContext(s_mockAgent, session, new AIContext()), CancellationToken.None); // Assert Assert.NotNull(capturedInput); Assert.Equal(string.Empty, capturedInput); // No recent messages in StateBag => empty input. } #endregion #region MessageAIContextProvider.InvokingAsync Tests [Fact] public async Task MessageInvokingAsync_BeforeAIInvoke_SearchesAndReturnsMergedMessagesAsync() { // Arrange List results = [ new() { SourceName = "Doc1", Text = "Content of Doc1" } ]; Task> SearchDelegateAsync(string input, CancellationToken ct) => Task.FromResult>(results); var provider = new TextSearchProvider(SearchDelegateAsync, new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke }); var inputMsg = new ChatMessage(ChatRole.User, "Question?"); var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [inputMsg]); // Act var messages = (await provider.InvokingAsync(context)).ToList(); // Assert - input message + search result message, with stamping Assert.Equal(2, messages.Count); Assert.Equal("Question?", messages[0].Text); Assert.Contains("Content of Doc1", messages[1].Text); Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, messages[1].GetAgentRequestMessageSourceType()); } [Fact] public async Task MessageInvokingAsync_OnDemand_ThrowsInvalidOperationExceptionAsync() { // Arrange var provider = new TextSearchProvider(this.NoResultSearchAsync, new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.OnDemandFunctionCalling, }); var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [new ChatMessage(ChatRole.User, "Q?")]); // Act & Assert await Assert.ThrowsAsync(() => provider.InvokingAsync(context).AsTask()); } [Fact] public async Task MessageInvokingAsync_BeforeAIInvoke_NoResults_ReturnsOnlyInputMessagesAsync() { // Arrange var provider = new TextSearchProvider(this.NoResultSearchAsync, new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke }); var inputMsg = new ChatMessage(ChatRole.User, "Hello"); var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [inputMsg]); // Act var messages = (await provider.InvokingAsync(context)).ToList(); // Assert Assert.Single(messages); Assert.Equal("Hello", messages[0].Text); } [Fact] public async Task MessageInvokingAsync_BeforeAIInvoke_DefaultFilter_ExcludesNonExternalMessagesAsync() { // Arrange string? capturedInput = null; Task> SearchDelegateAsync(string input, CancellationToken ct) { capturedInput = input; return Task.FromResult>([]); } var provider = new TextSearchProvider(SearchDelegateAsync, new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke }); var externalMsg = new ChatMessage(ChatRole.User, "External message"); var historyMsg = new ChatMessage(ChatRole.System, "From history") .WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, "src"); var context = new MessageAIContextProvider.InvokingContext(s_mockAgent, new TestAgentSession(), [externalMsg, historyMsg]); // Act await provider.InvokingAsync(context); // Assert - Only External message used for search query Assert.Equal("External message", capturedInput); } #endregion private Task> NoResultSearchAsync(string input, CancellationToken ct) { return Task.FromResult>([]); } private Task> FailingSearchAsync(string input, CancellationToken ct) { throw new InvalidOperationException("Search Failed"); } private sealed class RawPayload { public string Id { get; set; } = string.Empty; } private sealed class TestAgentSession : AgentSession { public TestAgentSession() { } public TestAgentSession(AgentSessionStateBag stateBag) { this.StateBag = stateBag; } } }