test
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
Python - Merge - Tests / paths-filter (push) Has been cancelled
Python - Merge - Tests / Python Tests - Core (integration, ubuntu-latest, 3.10) (push) Has been cancelled
Python - Merge - Tests / Python Tests - Azure AI (integration, ubuntu-latest, 3.10) (push) Has been cancelled
Python - Merge - Tests / python-integration-tests-check (push) Has been cancelled
Python - Lab Tests / paths-filter (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.10) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.11) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.12) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.13) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.14) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.10) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.11) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.12) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.13) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.14) (push) Has been cancelled
Check .md links / markdown-link-check (push) Has been cancelled
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
Python - Merge - Tests / paths-filter (push) Has been cancelled
Python - Merge - Tests / Python Tests - Core (integration, ubuntu-latest, 3.10) (push) Has been cancelled
Python - Merge - Tests / Python Tests - Azure AI (integration, ubuntu-latest, 3.10) (push) Has been cancelled
Python - Merge - Tests / python-integration-tests-check (push) Has been cancelled
Python - Lab Tests / paths-filter (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.10) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.11) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.12) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.13) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.14) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.10) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.11) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.12) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.13) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.14) (push) Has been cancelled
Check .md links / markdown-link-check (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,657 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Contains unit tests for <see cref="TextSearchProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class TextSearchProviderTests
|
||||
{
|
||||
private readonly Mock<ILogger<TextSearchProvider>> _loggerMock;
|
||||
private readonly Mock<ILoggerFactory> _loggerFactoryMock;
|
||||
|
||||
public TextSearchProviderTests()
|
||||
{
|
||||
this._loggerMock = new();
|
||||
this._loggerFactoryMock = new();
|
||||
this._loggerFactoryMock
|
||||
.Setup(f => f.CreateLogger(It.IsAny<string>()))
|
||||
.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<LogLevel>()))
|
||||
.Returns(true);
|
||||
}
|
||||
|
||||
[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<TextSearchProvider.TextSearchResult> 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<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
|
||||
}
|
||||
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
ContextPrompt = overrideContextPrompt,
|
||||
CitationsPrompt = overrideCitationsPrompt
|
||||
};
|
||||
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options, withLogging ? this._loggerFactoryMock.Object : null);
|
||||
|
||||
var invokingContext = new AIContextProvider.InvokingContext(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Sample user question?"),
|
||||
new ChatMessage(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);
|
||||
Assert.Single(aiContext.Messages!);
|
||||
var message = aiContext.Messages!.Single();
|
||||
Assert.Equal(ChatRole.User, message.Role);
|
||||
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<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("TextSearchProvider: Retrieved 2 search results.")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
this._loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Trace,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("TextSearchProvider: Search Results\nInput:Sample user question?\nAdditional part\nOutput")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
}
|
||||
|
||||
[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, default, null, options);
|
||||
var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "Q?")]);
|
||||
|
||||
// Act
|
||||
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(aiContext.Messages); // No automatic injection.
|
||||
Assert.NotNull(aiContext.Tools);
|
||||
Assert.Single(aiContext.Tools);
|
||||
var tool = aiContext.Tools.Single();
|
||||
Assert.Equal(expectedName, tool.Name);
|
||||
Assert.Equal(expectedDescription, tool.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TextSearchProvider(this.FailingSearchAsync, default, null, loggerFactory: this._loggerFactoryMock.Object);
|
||||
var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "Q?")]);
|
||||
|
||||
// Act
|
||||
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(aiContext.Messages);
|
||||
Assert.Null(aiContext.Tools);
|
||||
this._loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("TextSearchProvider: Failed to search for data due to error")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("Custom context prompt", "Custom citations prompt")]
|
||||
public async Task SearchAsync_ShouldReturnFormattedResultsAsync(string? overrideContextPrompt, string? overrideCitationsPrompt)
|
||||
{
|
||||
// Arrange
|
||||
List<TextSearchProvider.TextSearchResult> 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<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
|
||||
}
|
||||
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
ContextPrompt = overrideContextPrompt,
|
||||
CitationsPrompt = overrideCitationsPrompt
|
||||
};
|
||||
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, 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<TextSearchProvider.TextSearchResult> 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<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
|
||||
}
|
||||
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
ContextFormatter = r => $"Custom formatted context with {r.Count} results."
|
||||
};
|
||||
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
|
||||
var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "Q?")]);
|
||||
|
||||
// Act
|
||||
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(aiContext.Messages);
|
||||
Assert.Single(aiContext.Messages!);
|
||||
Assert.Equal("Custom formatted context with 2 results.", aiContext.Messages![0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsync_WithRawRepresentations_ContextFormatterCanAccessAsync()
|
||||
{
|
||||
// Arrange
|
||||
var payload1 = new RawPayload { Id = "R1" };
|
||||
var payload2 = new RawPayload { Id = "R2" };
|
||||
List<TextSearchProvider.TextSearchResult> results =
|
||||
[
|
||||
new() { SourceName = "Doc1", Text = "Content 1", RawRepresentation = payload1 },
|
||||
new() { SourceName = "Doc2", Text = "Content 2", RawRepresentation = payload2 }
|
||||
];
|
||||
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(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, default, null, options);
|
||||
var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "Q?")]);
|
||||
|
||||
// Act
|
||||
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(aiContext.Messages);
|
||||
Assert.Single(aiContext.Messages!);
|
||||
Assert.Equal("R1,R2", aiContext.Messages![0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsync_WithNoResults_ShouldReturnEmptyContextAsync()
|
||||
{
|
||||
// Arrange
|
||||
var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke };
|
||||
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
|
||||
var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "Q?")]);
|
||||
|
||||
// Act
|
||||
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(aiContext.Messages);
|
||||
Assert.Null(aiContext.Instructions);
|
||||
Assert.Null(aiContext.Tools);
|
||||
}
|
||||
|
||||
#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<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed.
|
||||
}
|
||||
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, 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"),
|
||||
};
|
||||
await provider.InvokedAsync(new(initialMessages, aiContextProviderMessages: null) { InvokeException = new InvalidOperationException("Request Failed") });
|
||||
|
||||
var invokingContext = new AIContextProvider.InvokingContext(
|
||||
[
|
||||
new ChatMessage(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<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed.
|
||||
}
|
||||
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, 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"),
|
||||
};
|
||||
await provider.InvokedAsync(new(initialMessages, aiContextProviderMessages: null));
|
||||
|
||||
var invokingContext = new AIContextProvider.InvokingContext(
|
||||
[
|
||||
new ChatMessage(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<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
|
||||
}
|
||||
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
|
||||
|
||||
// First memory update (A,B)
|
||||
await provider.InvokedAsync(new(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "A"),
|
||||
new ChatMessage(ChatRole.Assistant, "B"),
|
||||
], aiContextProviderMessages: null));
|
||||
|
||||
// Second memory update (C,D,E)
|
||||
await provider.InvokedAsync(new(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "C"),
|
||||
new ChatMessage(ChatRole.Assistant, "D"),
|
||||
new ChatMessage(ChatRole.User, "E"),
|
||||
], aiContextProviderMessages: null));
|
||||
|
||||
var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(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<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed for this test.
|
||||
}
|
||||
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
|
||||
|
||||
// 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(initialMessages, null));
|
||||
|
||||
var invokingContext = new AIContextProvider.InvokingContext(
|
||||
[
|
||||
new ChatMessage(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 void Serialize_WithNoRecentMessages_ShouldReturnEmptyState()
|
||||
{
|
||||
// Arrange
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 3
|
||||
};
|
||||
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
|
||||
|
||||
// Act
|
||||
var state = provider.Serialize();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(JsonValueKind.Object, state.ValueKind);
|
||||
Assert.False(state.TryGetProperty("recentMessagesText", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Serialize_WithRecentMessages_ShouldPersistMessagesUpToLimitAsync()
|
||||
{
|
||||
// Arrange
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 3,
|
||||
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
|
||||
};
|
||||
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
|
||||
var messages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "M1"),
|
||||
new ChatMessage(ChatRole.Assistant, "M2"),
|
||||
new ChatMessage(ChatRole.User, "M3"),
|
||||
};
|
||||
|
||||
// Act
|
||||
await provider.InvokedAsync(new(messages, aiContextProviderMessages: null)); // Populate recent memory.
|
||||
var state = provider.Serialize();
|
||||
|
||||
// Assert
|
||||
Assert.True(state.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 SerializeAndDeserialize_RoundtripRestoresMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 4,
|
||||
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
|
||||
};
|
||||
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
|
||||
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(messages, aiContextProviderMessages: null));
|
||||
|
||||
// Act
|
||||
var state = provider.Serialize();
|
||||
string? capturedInput = null;
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegate2Async(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
|
||||
}
|
||||
var roundTrippedProvider = new TextSearchProvider(SearchDelegate2Async, state, options: new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 4
|
||||
});
|
||||
var emptyMessages = Array.Empty<ChatMessage>();
|
||||
await roundTrippedProvider.InvokingAsync(new(emptyMessages), CancellationToken.None); // Trigger search to read memory.
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedInput);
|
||||
Assert.Equal("A\nB\nC\nD", capturedInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deserialize_WithChangedLowerLimit_ShouldTruncateToNewLimitAsync()
|
||||
{
|
||||
// Arrange
|
||||
var initialProvider = new TextSearchProvider(this.NoResultSearchAsync, default, null, new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 5,
|
||||
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
|
||||
});
|
||||
var messages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "L1"),
|
||||
new ChatMessage(ChatRole.Assistant, "L2"),
|
||||
new ChatMessage(ChatRole.User, "L3"),
|
||||
new ChatMessage(ChatRole.Assistant, "L4"),
|
||||
new ChatMessage(ChatRole.User, "L5"),
|
||||
};
|
||||
await initialProvider.InvokedAsync(new(messages, aiContextProviderMessages: null));
|
||||
var state = initialProvider.Serialize();
|
||||
|
||||
string? capturedInput = null;
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegate2Async(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
|
||||
}
|
||||
|
||||
// Act
|
||||
var restoredProvider = new TextSearchProvider(SearchDelegate2Async, state, options: new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 3 // Lower limit
|
||||
});
|
||||
await restoredProvider.InvokingAsync(new(Array.Empty<ChatMessage>()), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedInput);
|
||||
Assert.Equal("L1\nL2\nL3", capturedInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deserialize_WithEmptyState_ShouldHaveNoMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var emptyState = JsonSerializer.Deserialize("{}", TestJsonSerializerContext.Default.JsonElement);
|
||||
|
||||
string? capturedInput = null;
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegate2Async(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
|
||||
}
|
||||
|
||||
// Act
|
||||
var provider = new TextSearchProvider(SearchDelegate2Async, emptyState, options: new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 3
|
||||
});
|
||||
var emptyMessages = Array.Empty<ChatMessage>();
|
||||
await provider.InvokingAsync(new(emptyMessages), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedInput);
|
||||
Assert.Equal(string.Empty, capturedInput); // No recent messages serialized => empty input.
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private Task<IEnumerable<TextSearchProvider.TextSearchResult>> NoResultSearchAsync(string input, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
|
||||
}
|
||||
|
||||
private Task<IEnumerable<TextSearchProvider.TextSearchResult>> FailingSearchAsync(string input, CancellationToken ct)
|
||||
{
|
||||
throw new InvalidOperationException("Search Failed");
|
||||
}
|
||||
|
||||
private sealed class RawPayload
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user