using System.Globalization; using System.Text; using AIStudio.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools.Databases; using AIStudio.Tools.Databases.IndexStore; using AIStudio.Tools.Mail; using AIStudio.Tools.RAG; using AIStudio.Tools.Security; using AIStudio.Tools.Services; using AIStudio.Tools.ToolCallingSystem; using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mailboxes; using Microsoft.Extensions.Logging.Abstractions; namespace AIStudio.Tests.Tools.ToolCalling; /// /// Checks how Search Mails cuts what the mailboxes found into the pages the model reads. /// /// /// A search returns no more text than its budget allows, and nothing is kept between two searches. /// Still, every mail a mailbox found has to reach the model on exactly one page, so that turning /// pages brings up what did not fit before. FakeRuntime plays the filter for prompt injections, and /// each test makes up what the mailboxes found, page by page. /// [TestFixture] public sealed class SearchMailsToolPagingTests { /// /// What the filter removes. It is shorter than the marker which takes its place, so the filter makes a mail longer. /// private const string SUSPICIOUS_WORD = "PWNED"; /// /// How many attachments a long mail lists. /// private const int ATTACHMENTS = 20; /// /// How long the name of each attachment of a long mail is: twenty long mails fit into a page, twenty-one do not. /// private const int ATTACHMENT_NAME_LENGTH = 230; /// /// How many pages a test turns at most before it gives up on the search ever ending. /// private const int MOST_PAGES = 100; private static readonly DateTimeOffset RECEIVED = new(2026, 11, 5, 10, 0, 0, TimeSpan.Zero); // Pages of 30 mails, which leaves three pages of each mailbox: private static readonly DataSourceMailbox WORK = new() { Num = 1, Id = "9e4b2c7a-1d3f-4a8e-b6c5-2f0d7e1b3a64", Name = "Work", MaxMatches = 30 }; private static readonly DataSourceMailbox PRIVATE = new() { Num = 2, Id = "4c8d1e6b-3a9f-4b2c-8e7d-5f1a0b9c2d36", Name = "Private", MaxMatches = 30 }; private FakeRuntime runtime = null!; private RustService rustService = null!; private PromptInjectionGuardService guardService = null!; [SetUp] public void PrepareRuntime() { this.runtime = new FakeRuntime(SUSPICIOUS_WORD); this.rustService = new RustService(this.runtime); var settingsManager = new SettingsManager(NullLogger.Instance, this.rustService); this.guardService = new PromptInjectionGuardService(this.rustService, settingsManager, NullLogger.Instance, NullLoggerFactory.Instance); } [TearDown] public void DisposeRuntime() { this.rustService.Dispose(); this.runtime.Dispose(); } [Test] public async Task EveryMailReachesTheModelOnExactlyOnePage() { var pages = await this.PageThroughAsync(Search(WORK), Found(Mails(1, 30, isLong: true), Mails(31, 30, isLong: true), Mails(61, 30, isLong: true))); using (Assert.EnterMultipleScope()) { Assert.That(pages.SelectMany(NumbersOf), Is.EqualTo(Enumerable.Range(1, 90)), "Turning pages has to bring up every mail which did not fit before, in the order the mailbox ranked them."); Assert.That(pages.Select(CharactersOf), Has.All.LessThanOrEqualTo(SearchMailsTool.MAX_RESULT_CHARACTERS)); Assert.That(pages.Select(page => page.Passages.Count), Is.EqualTo(pages.Select(page => NumbersOf(page).Count)), "The user is shown exactly the mails the model received."); Assert.That(pages.Select(page => page.Sources.Count), Is.EqualTo(pages.Select(page => NumbersOf(page).Count))); } } [Test] public async Task WhereEverythingFitsAPageIsWhatTheMailboxFound() { var pages = await this.PageThroughAsync(Search(WORK), Found(Mails(1, 30), Mails(31, 30))); Assert.That(pages.Select(NumbersOf), Is.EqualTo(new[] { Enumerable.Range(1, 30).ToList(), Enumerable.Range(31, 30).ToList() })); } [Test] public async Task AnEmptyPageOfTheMailboxDoesNotEndThePaging() { var pages = await this.PageThroughAsync(Search(WORK), Found(Mails(1, 30), [], Mails(31, 30))); Assert.That(pages.Select(NumbersOf), Is.EqualTo(new[] { Enumerable.Range(1, 30).ToList(), Enumerable.Range(31, 30).ToList() }), "A page of the mailbox can turn out empty, when its chunks belong to mails of the pages before, and the next one still holds mails."); } [Test] public async Task ThereIsNothingMoreWhenTheFurtherPagesOfTheMailboxAreEmpty() { var result = await this.SearchAsync([Search(WORK)], 1, Found(Mails(1, 30), [])); Assert.That(result.Outcomes.Single().HasMore, Is.False, "The mailbox only said that its next page might hold more. The model must not be sent there for nothing."); } [Test] public async Task APageBeyondTheLastIsRefusedWithTheLastPage() { var found = Found(Mails(1, 30, isLong: true)); var pages = await this.PageThroughAsync(Search(WORK), found); var message = (await Assert.ThrowsAsync(() => this.SearchAsync([Search(WORK)], pages.Count + 1, found)))!.Message; using (Assert.EnterMultipleScope()) { Assert.That(pages.Select(page => NumbersOf(page).Count), Is.EqualTo(new[] { 20, 10 })); Assert.That(message, Does.Contain("at most 2").And.Contain("but was 3")); } } [Test] public async Task SeveralMailboxesTakeTurnsUntilTheirNextMailNoLongerFits() { var work = Found(Mails(1, 30, isLong: true)); var @private = Found([..Mails(101, 12, isLong: true), ..Mails(113, 18)]); var result = await this.SearchAsync([Search(WORK), Search(PRIVATE)], 1, (search, page) => search.Mailbox == WORK ? work(search, page) : @private(search, page)); using (Assert.EnterMultipleScope()) { Assert.That(result.Outcomes.Select(outcome => NumbersOf(outcome).ToList()), Is.EqualTo(new[] { Enumerable.Range(1, 10).ToList(), Enumerable.Range(101, 10).ToList() }), "The short mails of the private mailbox rank below one which did not fit, so they must not take its place."); Assert.That(result.Outcomes.Select(outcome => (outcome.Mails.Count, outcome.LeftOutCount, outcome.HasMore)), Is.EqualTo(new[] { (10, 20, true), (10, 20, true) })); Assert.That(CharactersOf(result), Is.LessThanOrEqualTo(SearchMailsTool.MAX_RESULT_CHARACTERS)); } } [Test] public void ASearchOfSeveralMailboxesNamesTheWayToTheMailsLeftOut() { var description = SearchMailsTool.DescribeMailbox(new MailboxSearchOutcome(Search(WORK), [], true, [], 3, null), null, TimeZoneInfo.Utc); var issue = description["issues"]?.AsArray().Select(node => node!.GetValue()).SingleOrDefault(text => text.Contains("did not fit", StringComparison.Ordinal)); Assert.That(issue, Does.Contain("3 further mails of this mailbox did not fit into this search of several mailboxes").And.Contain("Search this mailbox alone, starting at page 1")); } [Test] public async Task MailsAreMeasuredAfterTheFilter() { // Twenty long mails fit into a page, but not once the filter put its markers in: var pages = await this.PageThroughAsync(Search(WORK), Found(Mails(1, 20, isLong: true, suspiciousWords: 3))); using (Assert.EnterMultipleScope()) { Assert.That(pages.Select(NumbersOf), Is.EqualTo(new[] { Enumerable.Range(1, 19).ToList(), [20] })); Assert.That(pages.Select(CharactersOf), Has.All.LessThanOrEqualTo(SearchMailsTool.MAX_RESULT_CHARACTERS)); } } [Test] public async Task AMailboxWhichFailsOnAnEarlierPageCountsAsNotSearched() { var result = await this.SearchAsync([Search(WORK)], 2, (_, _) => Task.FromResult(MailSearchPage.EMPTY with { Gaps = [RetrievalGap.NOT_SEARCHED] })); var outcome = result.Outcomes.Single(); using (Assert.EnterMultipleScope()) { Assert.That(outcome.Gaps, Is.EqualTo(new[] { RetrievalGap.NOT_SEARCHED }), "Without the pages before it, nobody knows where the page starts."); Assert.That(outcome.HasMore, Is.False); Assert.That(outcome.Mails, Is.Empty); } } [Test] public async Task AMailboxWithoutTheFolderNamedListsItsFoldersInsteadOfSearching() { var coverage = new MailboxCoverage(null, null, null, null, [new MailFolderRecord("INBOX", MailFolderSpecialUse.NONE, 1, null, null, null, null, null), new MailFolderRecord("Archive", MailFolderSpecialUse.NONE, 1, null, null, null, null, null)]); var search = new MailboxSearch(WORK, coverage, new MailFilter(), FolderIsMissing: true, MailDraftSyncDecision.NOT_NEEDED); var result = await this.SearchAsync([search], 1, (_, _) => throw new AssertionException("A mailbox without the folder named is not searched.")); var outcome = result.Outcomes.Single(); using (Assert.EnterMultipleScope()) { Assert.That(outcome.ListedFolders, Is.EquivalentTo(new[] { "INBOX", "Archive" })); Assert.That(outcome.Mails, Is.Empty); Assert.That(outcome.HasMore, Is.False); } } [Test] public async Task AMailAtItsLongestStaysFarBelowAPage() { // Every text far too long, of characters the model receives escaped, six for each of them: var hit = new MailSearchHit( new MailSummary( "mail-001", Escaped(10_000), RECEIVED, null, string.Empty, string.Empty, [new MailAddressRecord(MailAddressRole.FROM, Escaped(10_000), Escaped(10_000)), ..Enumerable.Range(1, 50).Select(_ => new MailAddressRecord(MailAddressRole.TO, Escaped(1_000), Escaped(1_000)))], [..Enumerable.Range(1, 50).Select(_ => Escaped(1_000))], new MailFlags(IsSeen: true, IsFlagged: false, IsAnswered: false), MailImportance.NORMAL, MailEncryptionKind.NONE, [..Enumerable.Range(1, 500).Select(_ => Escaped(1_000))]), Escaped(100_000), new ChunkLocation(AttachmentPosition: 499)); var result = await this.SearchAsync([Search(WORK)], 1, Found([hit])); var mail = result.Outcomes.Single().Mails.SingleOrDefault(); using (Assert.EnterMultipleScope()) { Assert.That(CharactersOf(result), Is.LessThan(SearchMailsTool.MAX_RESULT_CHARACTERS), "A single mail must never fill a page on its own."); Assert.That(mail?["more_recipients"]?.GetValue(), Is.EqualTo(45)); Assert.That(mail?["more_folders"]?.GetValue(), Is.EqualTo(40)); Assert.That(mail?["more_attachments"]?.GetValue(), Is.EqualTo(480), "The model learns that there are more attachments, and read_mail lists them all."); } } [Test] public async Task AMailNamesTheAttachmentItsPassageIsFromAlsoBeyondTheListedOnes() { var mail = Mail(1, isLong: false, suspiciousWords: 0); var hit = mail with { Summary = mail.Summary with { AttachmentNames = [..Enumerable.Range(1, 30).Select(position => $"report-{position:D2}.txt")] }, PassageLocation = new ChunkLocation(StartLine: 3, EndLine: 4, AttachmentPosition: 25), }; var result = await this.SearchAsync([Search(WORK)], 1, Found([hit])); var description = result.Outcomes.Single().Mails.SingleOrDefault(); using (Assert.EnterMultipleScope()) { Assert.That(description?["attachments"]?.AsArray(), Has.Count.EqualTo(20)); Assert.That(description?["more_attachments"]?.GetValue(), Is.EqualTo(10)); Assert.That(description?["passage_location"]?.GetValue(), Does.StartWith("attachment \"report-26.txt\""), "Otherwise, the model would take the passage for one of the text of the mail."); Assert.That(result.Sources.SingleOrDefault()?.Title, Does.Contain("report-26.txt")); } } /// /// Turns the pages of a search in one mailbox, until it says there is nothing more. /// private async Task> PageThroughAsync(MailboxSearch search, Func> retrievePage) { var pages = new List(); for (var page = 1; page <= MOST_PAGES; page++) { var result = await this.SearchAsync([search], page, retrievePage); pages.Add(result); if (!result.Outcomes.Single().HasMore) return pages; } Assert.Fail($"The search still had more after {MOST_PAGES} pages."); return pages; } private async Task SearchAsync(IReadOnlyList searches, int page, Func> retrievePage) { await using var reportingScope = this.guardService.BeginAction(); return await SearchMailsTool.SearchAsync(searches, page, retrievePage, this.guardService, reportingScope, TimeZoneInfo.Utc); } private static MailboxSearch Search(DataSourceMailbox mailbox) => new(mailbox, null, new MailFilter(), FolderIsMissing: false, MailDraftSyncDecision.NOT_NEEDED); /// /// Plays what a mailbox found, page by page: each page as given, and nothing after the last one. /// private static Func> Found(params IReadOnlyList[] retrievalPages) => (_, page) => Task.FromResult(page <= retrievalPages.Length ? new MailSearchPage(retrievalPages[page - 1], page < retrievalPages.Length, []) : MailSearchPage.EMPTY); private static IReadOnlyList Mails(int firstNumber, int count, bool isLong = false, int suspiciousWords = 0) => Enumerable.Range(firstNumber, count).Select(number => Mail(number, isLong, suspiciousWords)).ToList(); /// /// A mail whose ID ends in its number. A long one lists many attachments with long names, the first of which holds the suspicious word as often as asked for. /// private static MailSearchHit Mail(int number, bool isLong, int suspiciousWords) { IReadOnlyList attachmentNames = isLong ? [..Enumerable.Range(1, ATTACHMENTS).Select(position => Text($"attachment-{position:D2} ", ATTACHMENT_NAME_LENGTH, position == 1 ? suspiciousWords : 0))] : []; var summary = new MailSummary( $"mail-{number:D3}", $"Mail {number:D3}", RECEIVED, null, string.Empty, string.Empty, [new MailAddressRecord(MailAddressRole.FROM, "anna@example.com", "Anna")], ["INBOX"], new MailFlags(IsSeen: true, IsFlagged: false, IsAnswered: false), MailImportance.NORMAL, MailEncryptionKind.NONE, attachmentNames); return new(summary, $"Passage of mail {number:D3}"); } /// /// A text which starts as given, holds the suspicious word as often as asked for, and is as long as asked for. /// private static string Text(string start, int length, int suspiciousWords) { var text = new StringBuilder(start); for (var word = 0; word < suspiciousWords; word++) text.Append(SUSPICIOUS_WORD).Append(' '); text.Append('x', Math.Max(0, length - text.Length)); return text.ToString(); } /// /// A text of a control character, which JSON writes as an escape of six characters. /// private static string Escaped(int length) => new('\u0001', length); /// /// The numbers of the mails the model reads on a page, in the order it reads them. /// private static List NumbersOf(SearchMailsResult result) => result.Outcomes.SelectMany(NumbersOf).ToList(); private static IEnumerable NumbersOf(MailboxSearchOutcome outcome) => outcome.Mails.Select(mail => int.Parse(mail["mail_id"]!.GetValue()["mail-".Length..], CultureInfo.InvariantCulture)); /// /// How many characters the mails of a page take, as the model receives them. /// private static int CharactersOf(SearchMailsResult result) => result.Outcomes.SelectMany(outcome => outcome.Mails).Sum(mail => mail.ToJsonString(ToolExecutionResult.MODEL_CONTENT_OPTIONS).Length); }