mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-06 11:09:40 +00:00
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
181 lines
8.4 KiB
C#
181 lines
8.4 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
|
|
using AIStudio.Settings.DataModel;
|
|
using AIStudio.Tools.Databases.IndexStore;
|
|
using AIStudio.Tools.Mail;
|
|
using AIStudio.Tools.ToolCallingSystem;
|
|
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mailboxes;
|
|
|
|
namespace AIStudio.Tests.Tools.ToolCalling;
|
|
|
|
/// <summary>
|
|
/// Checks how the mail tools read the conditions a model sets for the mails.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// "Unread mails from Alice since Monday" has to become exactly these conditions, or the answer
|
|
/// tells the user something wrong with confidence. So every condition is read strictly, and one
|
|
/// that cannot be meant as written is refused with what would have been right.
|
|
/// </remarks>
|
|
[TestFixture]
|
|
public sealed class MailToolArgumentsTests
|
|
{
|
|
private static readonly DataSourceMailbox WORK = new() { Id = "2b7e4c1a-9d3f-4e8b-a6c5-1f0d8e3b7a92", Name = "Work" };
|
|
private static readonly DataSourceMailbox PRIVATE = new() { Id = "7c1d9e4b-3a8f-4b2e-9d6c-5e0a2f8b1c73", Name = "Private" };
|
|
private static readonly IReadOnlyList<DataSourceMailbox> OFFERED = [WORK, PRIVATE];
|
|
|
|
[Test]
|
|
public void EveryConditionBecomesPartOfTheFilter()
|
|
{
|
|
var conditions = MailToolArguments.ReadConditions(Arguments("""
|
|
{
|
|
"from": " alice@example.org ",
|
|
"to": "Bob",
|
|
"after": "2026-09-01",
|
|
"before": "2026-09-30T18:00",
|
|
"is_unread": true,
|
|
"is_flagged": false,
|
|
"is_encrypted": false,
|
|
"importance": "high",
|
|
"has_attachments": true,
|
|
"folder": "Archive/2026"
|
|
}
|
|
"""), UserTimeZone());
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(conditions.Filter, Is.EqualTo(new MailFilter
|
|
{
|
|
From = "alice@example.org",
|
|
To = "Bob",
|
|
ReceivedSinceUtc = new DateTimeOffset(2026, 8, 31, 22, 0, 0, TimeSpan.Zero),
|
|
ReceivedBeforeUtc = new DateTimeOffset(2026, 9, 30, 16, 0, 0, TimeSpan.Zero),
|
|
IsUnread = true,
|
|
IsFlagged = false,
|
|
IsEncrypted = false,
|
|
Importance = MailImportance.HIGH,
|
|
HasAttachments = true,
|
|
}));
|
|
Assert.That(conditions.Folder, Is.EqualTo("Archive/2026"));
|
|
});
|
|
}
|
|
|
|
[TestCase("""{}""")]
|
|
[TestCase("""{"from":null,"to":null,"after":null,"before":null,"is_unread":null,"is_flagged":null,"is_encrypted":null,"importance":null,"has_attachments":null,"folder":null}""")]
|
|
public void ConditionsLeftOutHoldForEveryMail(string json)
|
|
{
|
|
var conditions = MailToolArguments.ReadConditions(Arguments(json), UserTimeZone());
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(conditions.Filter.HasConditions, Is.False, "A strict schema makes the model pass null for every condition it does not want.");
|
|
Assert.That(conditions.Folder, Is.Null);
|
|
});
|
|
}
|
|
|
|
[TestCase("2026-09-30", "2026-09-01")]
|
|
[TestCase("2026-09-01", "2026-09-01")]
|
|
public void APeriodWhichEndsBeforeItStartsIsRefused(string after, string before)
|
|
{
|
|
var message = Refusal(() => MailToolArguments.ReadConditions(Arguments($$"""{"after":"{{after}}","before":"{{before}}"}"""), UserTimeZone()));
|
|
|
|
Assert.That(message, Does.Contain("'after' must lie before argument 'before'").And.Contain("Swap the two, or leave one of them out."));
|
|
}
|
|
|
|
[TestCase("""{"importance":"urgent"}""", "'importance' must be one of low, normal, high")]
|
|
[TestCase("""{"importance":"HIGH"}""", "'importance' must be one of low, normal, high")]
|
|
[TestCase("""{"is_unread":"true"}""", "'is_unread' must be true or false")]
|
|
[TestCase("""{"after":"last week"}""", "'after' must be a date")]
|
|
[TestCase("""{"from":""}""", "'from' must not be empty")]
|
|
[TestCase("""{"folder":"INBOX\nArchive"}""", "'folder' must not contain control characters")]
|
|
public void AConditionWhichCannotBeMeantAsWrittenIsRefused(string json, string expectedMessage)
|
|
{
|
|
var message = Refusal(() => MailToolArguments.ReadConditions(Arguments(json), UserTimeZone()));
|
|
|
|
Assert.That(message, Does.Contain(expectedMessage).And.Contain("Leave it out"));
|
|
}
|
|
|
|
[Test]
|
|
public void TheModelPicksSomeOfTheOfferedMailboxes()
|
|
{
|
|
var all = MailToolArguments.ReadMailboxes(Arguments("""{}"""), OFFERED, "search");
|
|
var some = MailToolArguments.ReadMailboxes(Arguments($$"""{"mailbox_ids":["{{PRIVATE.Id}}"]}"""), OFFERED, "search");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(all.Select(mailbox => mailbox.Id), Is.EqualTo(new[] { WORK.Id, PRIVATE.Id }));
|
|
Assert.That(some.Select(mailbox => mailbox.Id), Is.EqualTo(new[] { PRIVATE.Id }));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void AMailboxNotOfferedIsRefused()
|
|
{
|
|
var message = Refusal(() => MailToolArguments.ReadMailboxes(Arguments("""{"mailbox_ids":["3f9a1c7e-5b2d-4e8a-b1c6-9d0e7f2a4b58"]}"""), OFFERED, "search"));
|
|
|
|
Assert.That(message, Does.Contain(WORK.Id).And.Contain(PRIVATE.Id).And.Contain("Leave it out to search all listed mailboxes."), "The refusal names the mailboxes the model may search.");
|
|
}
|
|
|
|
[Test]
|
|
public void AFolderIsFoundByItsFullPathRegardlessOfCase()
|
|
{
|
|
var folders = new[] { Folder("INBOX"), Folder("INBOX/Projects"), Folder("Archive"), Folder("archive") };
|
|
|
|
var inbox = new MailConditions(new MailFilter(), "Inbox").ForMailbox(folders);
|
|
var archive = new MailConditions(new MailFilter(), "ARCHIVE").ForMailbox(folders);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(inbox.FolderPaths, Is.EqualTo(new[] { "INBOX" }), "Its subfolders are folders of their own.");
|
|
Assert.That(archive.FolderPaths, Is.EqualTo(new[] { "Archive", "archive" }), "The model cannot tell two folders apart which differ only in case.");
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void AFolderTheMailboxDoesNotHaveMatchesNoMail()
|
|
{
|
|
var filter = new MailConditions(new MailFilter { IsUnread = true }, "Projects").ForMailbox([Folder("INBOX")]);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(filter.FolderPaths, Is.Empty, "An empty list matches no mail; a missing one would match every mail.");
|
|
Assert.That(filter.IsUnread, Is.True, "The other conditions stay.");
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void WithoutAFolderEveryFolderCounts()
|
|
{
|
|
Assert.That(new MailConditions(new MailFilter(), null).ForMailbox([Folder("INBOX")]).FolderPaths, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
public void TheSchemaOffersEveryConditionAndOnlyTheOfferedMailboxes()
|
|
{
|
|
var schema = JsonNode.Parse(ToolParameterSchemaBuilder.Create().AddMailConditions("search", WORK.Id, PRIVATE.Id).Build().GetRawText())!;
|
|
var properties = schema["properties"]!.AsObject();
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(properties.Select(property => property.Key), Is.EquivalentTo(new[]
|
|
{
|
|
MailToolArguments.MAILBOX_IDS_ARGUMENT, MailToolArguments.FROM_ARGUMENT, MailToolArguments.TO_ARGUMENT, MailToolArguments.AFTER_ARGUMENT, MailToolArguments.BEFORE_ARGUMENT,
|
|
MailToolArguments.IS_UNREAD_ARGUMENT, MailToolArguments.IS_FLAGGED_ARGUMENT, MailToolArguments.IS_ENCRYPTED_ARGUMENT, MailToolArguments.IMPORTANCE_ARGUMENT,
|
|
MailToolArguments.HAS_ATTACHMENTS_ARGUMENT, MailToolArguments.FOLDER_ARGUMENT,
|
|
}));
|
|
Assert.That(properties[MailToolArguments.MAILBOX_IDS_ARGUMENT]!["items"]!["enum"]!.AsArray().Select(id => id!.GetValue<string>()), Is.EqualTo(new[] { WORK.Id, PRIVATE.Id }));
|
|
Assert.That(schema["required"]!.AsArray(), Is.Empty, "Every condition may be left out.");
|
|
});
|
|
}
|
|
|
|
private static MailFolderRecord Folder(string path) => new(path, MailFolderSpecialUse.NONE, 1, null, null, null, null, null);
|
|
|
|
private static JsonElement Arguments(string json) => JsonSerializer.Deserialize<JsonElement>(json);
|
|
|
|
private static string Refusal(TestDelegate read) => Assert.Throws<ArgumentException>(read)!.Message;
|
|
|
|
/// <summary>
|
|
/// A time zone two hours ahead of UTC, all year round, so that no test depends on the computer or on daylight saving time.
|
|
/// </summary>
|
|
private static TimeZoneInfo UserTimeZone() => TimeZoneInfo.CreateCustomTimeZone("AI Studio test zone", TimeSpan.FromHours(2), "AI Studio test zone", "AI Studio test zone");
|
|
} |