forked from admin/VolcanicAgenticCore
253 lines
12 KiB
C#
253 lines
12 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using Volcanic.AgenticCore.Telegram.Groups;
|
|
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
|
using Volcanic.AgenticCore.Telegram.Triggers;
|
|
|
|
namespace Volcanic.AgenticCore.Telegram.Tests;
|
|
|
|
/// <summary>Classification, logging, the debounce window and snoozes, with a fake Jev and a recording delivery.</summary>
|
|
public sealed class TriggerEngineTests : IAsyncDisposable
|
|
{
|
|
private const long Owner = 1001;
|
|
private const long OtherOwner = 2002;
|
|
private const long Group = -1001234567890;
|
|
private static readonly DateTimeOffset Start = new(2026, 10, 5, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "volcanic-engine", Guid.NewGuid().ToString("N"));
|
|
private readonly FakeTimeProvider _time = new(Start);
|
|
private readonly FakeJev _jev = new();
|
|
private readonly RecordingDelivery _delivery = new();
|
|
private readonly ListLogger<TriggerEngine> _log = new();
|
|
private readonly TriggerOptions _options = new() { ApiKey = "k", Debounce = TimeSpan.FromSeconds(10) };
|
|
private readonly TriggerStore _store;
|
|
private readonly TriggerDispatcher _dispatcher;
|
|
private readonly TriggerEngine _engine;
|
|
|
|
public TriggerEngineTests()
|
|
{
|
|
_store = new TriggerStore(Path.Combine(_root, "workspaces"), 20, NullLogger<TriggerStore>.Instance, _time);
|
|
_dispatcher = new TriggerDispatcher(_delivery, _options, _time, NullLogger<TriggerDispatcher>.Instance);
|
|
_engine = new TriggerEngine(_store, new SnoozeQueue(100), _jev, _dispatcher, _delivery, _time, _log);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _engine.IdleAsync();
|
|
_engine.Dispose();
|
|
_dispatcher.Dispose();
|
|
try { Directory.Delete(_root, true); } catch (IOException) { }
|
|
}
|
|
|
|
private Task<Trigger> AddAsync(long owner = Owner, string name = "deploys", double threshold = 0.7) =>
|
|
_store.AddAsync(owner, new NewTrigger(name, Group, "Dev Team", "Is this about deploys?", "Mentions deploys", "Small talk", threshold), CancellationToken.None);
|
|
|
|
private static GroupMessage Message(string? text, int id = 1, int minute = 2) =>
|
|
new(Group, "Dev Team", null, true, id, Start.AddMinutes(minute), "Alice (@alice)", text);
|
|
|
|
private async Task AdvanceAsync(TimeSpan by)
|
|
{
|
|
_time.Advance(by);
|
|
await _engine.IdleAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_matching_message_reaches_the_owner_once_the_window_closes()
|
|
{
|
|
var trigger = await AddAsync();
|
|
|
|
await _engine.ProcessAsync(Message("prod deploy is failing again"), CancellationToken.None);
|
|
await AdvanceAsync(TimeSpan.FromSeconds(9));
|
|
Assert.Empty(_delivery.Prompts);
|
|
await AdvanceAsync(TimeSpan.FromSeconds(1));
|
|
|
|
var (owner, prompt) = Assert.Single(_delivery.Prompts);
|
|
Assert.Equal(Owner, owner);
|
|
Assert.Equal($"[Trigger \"deploys\" ({trigger.Id}) matched 1 message in \"Dev Team\"]\n12:02 Alice (@alice), score 0.95: prod deploy is failing again\n https://t.me/c/1234567890/1", prompt);
|
|
var question = Assert.Single(Assert.Single(_jev.Calls).Questions);
|
|
Assert.Equal(("Is this about deploys?", "Mentions deploys", "Small talk"), (question.Instructions, question.CriteriaTrue, question.CriteriaFalse));
|
|
Assert.Contains(_log.Entries, e => e.Text == "Group \"Dev Team\" (-1001234567890) msg 1 from Alice (@alice): \"prod deploy is failing again\" → 1001/t1 deploys=0.95 MATCH");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Matches_in_one_window_become_one_prompt_and_later_ones_do_not_extend_it()
|
|
{
|
|
await AddAsync();
|
|
|
|
await _engine.ProcessAsync(Message("deploy started", 1), CancellationToken.None);
|
|
await AdvanceAsync(TimeSpan.FromSeconds(6));
|
|
await _engine.ProcessAsync(Message("lunch?", 2), CancellationToken.None);
|
|
await _engine.ProcessAsync(Message("deploy done", 3), CancellationToken.None);
|
|
await AdvanceAsync(TimeSpan.FromSeconds(4)); // 10 s after the first match
|
|
|
|
var prompt = Assert.Single(_delivery.Prompts).Prompt;
|
|
Assert.StartsWith("[Trigger \"deploys\" (t1) matched 2 messages", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("deploy started", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("deploy done", prompt, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("lunch", prompt, StringComparison.Ordinal);
|
|
Assert.Contains(_log.Entries, e => e.Text.EndsWith("\"lunch?\" → 1001/t1 deploys=0.05", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Every_owners_trigger_on_the_group_is_asked_in_one_call()
|
|
{
|
|
await AddAsync();
|
|
await AddAsync(OtherOwner, "outages", threshold: 0.99);
|
|
|
|
await _engine.ProcessAsync(Message("deploy broke prod"), CancellationToken.None);
|
|
await AdvanceAsync(TimeSpan.FromSeconds(10));
|
|
|
|
Assert.Equal(2, Assert.Single(_jev.Calls).Questions.Count);
|
|
Assert.Equal(Owner, Assert.Single(_delivery.Prompts).Owner); // 0.95 is under the other trigger's 0.99
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Messages_without_text_or_triggers_are_logged_but_not_classified()
|
|
{
|
|
await _engine.ProcessAsync(Message("deploy"), CancellationToken.None);
|
|
await AddAsync();
|
|
await _engine.ProcessAsync(Message(null, 2), CancellationToken.None);
|
|
|
|
Assert.Empty(_jev.Calls);
|
|
Assert.Contains(_log.Entries, e => e.Text.EndsWith("\"deploy\" → no triggers", StringComparison.Ordinal));
|
|
Assert.Contains(_log.Entries, e => e.Text.EndsWith("msg 2 from Alice (@alice): (no text) → skipped", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_jev_failure_is_logged_as_a_warning_and_drops_the_message()
|
|
{
|
|
await AddAsync();
|
|
_jev.Failure = new JevException("Jev: HTTP 503 busy") { Attempts = 4 };
|
|
|
|
await _engine.ProcessAsync(Message("deploy"), CancellationToken.None);
|
|
await AdvanceAsync(TimeSpan.FromSeconds(10));
|
|
|
|
Assert.Empty(_delivery.Prompts);
|
|
Assert.Contains(_log.Entries, e => e.Level == LogLevel.Warning && e.Text.EndsWith("→ Jev failed after 4 attempts: Jev: HTTP 503 busy", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_snoozed_trigger_queues_and_its_batch_goes_to_jev_once_when_the_snooze_ends()
|
|
{
|
|
await AddAsync();
|
|
await _engine.StartAsync(CancellationToken.None);
|
|
await _engine.SnoozeAsync(Owner, null, Start.AddHours(1), CancellationToken.None);
|
|
|
|
await _engine.ProcessAsync(Message("anyone for pizza?", 1, 5), CancellationToken.None);
|
|
await _engine.ProcessAsync(Message("prod is down after the deploy", 2, 20), CancellationToken.None);
|
|
Assert.Empty(_jev.Calls);
|
|
Assert.Equal(2, _engine.Queued(Owner, "t1"));
|
|
Assert.Contains(_log.Entries, e => e.Text.EndsWith("→ 1001/t1 deploys snoozed (queued 2)", StringComparison.Ordinal));
|
|
|
|
await AdvanceAsync(TimeSpan.FromHours(1));
|
|
|
|
var call = Assert.Single(_jev.Calls);
|
|
Assert.StartsWith("Answer yes if at least one message in the batch passes this check: ", Assert.Single(call.Questions).Instructions, StringComparison.Ordinal);
|
|
Assert.Equal(2, call.State["messages"]!.AsArray().Count);
|
|
var prompt = Assert.Single(_delivery.Prompts).Prompt;
|
|
Assert.StartsWith("[Trigger \"deploys\" (t1): snooze ended; 2 messages from \"Dev Team\" since Mon 05 Oct 12:05 contain related ones (score 0.95)]", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("12:05 Alice (@alice): anyone for pizza?", prompt, StringComparison.Ordinal);
|
|
Assert.Null(_store.Find(Owner, "t1")!.SnoozedUntil);
|
|
Assert.Equal(0, _engine.Queued(Owner, "t1"));
|
|
|
|
await _engine.ProcessAsync(Message("deploy again", 3, 70), CancellationToken.None); // live again
|
|
Assert.Equal(2, _jev.Calls.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_unrelated_snooze_batch_stays_silent()
|
|
{
|
|
await AddAsync();
|
|
await _engine.StartAsync(CancellationToken.None);
|
|
await _engine.SnoozeAsync(Owner, "t1", Start.AddMinutes(30), CancellationToken.None);
|
|
await _engine.ProcessAsync(Message("pizza", 1), CancellationToken.None);
|
|
|
|
await AdvanceAsync(TimeSpan.FromMinutes(30));
|
|
|
|
Assert.Single(_jev.Calls);
|
|
Assert.Empty(_delivery.Prompts);
|
|
Assert.Contains(_log.Entries, e => e.Text.EndsWith("snooze ended, 1 messages, best 0.05 → no match", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unsnooze_flushes_now_and_reports_the_result()
|
|
{
|
|
await AddAsync();
|
|
await _engine.SnoozeAsync(Owner, null, Start.AddDays(1), CancellationToken.None);
|
|
await _engine.ProcessAsync(Message("deploy", 1), CancellationToken.None);
|
|
|
|
var flush = Assert.Single(await _engine.UnsnoozeAsync(Owner, null, CancellationToken.None));
|
|
await _engine.IdleAsync();
|
|
|
|
Assert.Equal((1, true), (flush.Count, flush.Matched));
|
|
Assert.Single(_delivery.Prompts);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_snooze_that_ended_while_the_host_was_down_is_flushed_at_start()
|
|
{
|
|
await AddAsync();
|
|
await _engine.SnoozeAsync(Owner, null, Start.AddMinutes(-1), CancellationToken.None); // already over
|
|
await new SnoozeQueue(10).AppendAsync(_store.WorkspaceOf(Owner), "t1", Message("deploy"), CancellationToken.None);
|
|
|
|
await _engine.StartAsync(CancellationToken.None);
|
|
await _engine.IdleAsync();
|
|
|
|
Assert.Single(_delivery.Prompts);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_large_batch_is_chunked_and_delivered_as_a_digest_file()
|
|
{
|
|
await AddAsync();
|
|
await _engine.SnoozeAsync(Owner, null, Start.AddDays(1), CancellationToken.None);
|
|
var text = new string('x', 1000);
|
|
for (var i = 1; i <= 60; i++) await _engine.ProcessAsync(Message(i == 60 ? "deploy " + text : text, i), CancellationToken.None);
|
|
|
|
await _engine.UnsnoozeAsync(Owner, "t1", CancellationToken.None);
|
|
await _engine.IdleAsync();
|
|
|
|
Assert.Equal(2, _jev.Calls.Count); // 60 KB of state in 40 KB chunks
|
|
var prompt = Assert.Single(_delivery.Prompts).Prompt;
|
|
var relative = prompt[(prompt.IndexOf("The messages are in ", StringComparison.Ordinal) + 20)..prompt.IndexOf("; read it", StringComparison.Ordinal)];
|
|
var digest = await File.ReadAllTextAsync(Path.Combine(_store.WorkspaceOf(Owner), relative));
|
|
Assert.Contains("deploy xxx", digest, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deleting_a_trigger_deletes_its_queue_and_a_disabled_trigger_ignores_messages()
|
|
{
|
|
await AddAsync();
|
|
await AddAsync(name: "other");
|
|
await _store.UpdateAsync(Owner, "t2", t => t with { Enabled = false }, CancellationToken.None);
|
|
await _engine.SnoozeAsync(Owner, "t1", Start.AddDays(1), CancellationToken.None);
|
|
await _engine.ProcessAsync(Message("deploy"), CancellationToken.None);
|
|
|
|
await _engine.DeleteAsync(Owner, "t1", CancellationToken.None);
|
|
await _engine.ProcessAsync(Message("deploy", 2), CancellationToken.None);
|
|
|
|
Assert.False(File.Exists(SnoozeQueue.PathFor(_store.WorkspaceOf(Owner), "t1")));
|
|
Assert.Empty(_jev.Calls);
|
|
Assert.Contains(_log.Entries, e => e.Text.EndsWith("\"deploy\" → no triggers", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void Durations_and_until_times_parse()
|
|
{
|
|
Assert.True(TriggerPrompts.TryParseDuration("1h30m", out var d) && d == TimeSpan.FromMinutes(90));
|
|
Assert.True(TriggerPrompts.TryParseDuration("2d", out d) && d == TimeSpan.FromDays(2));
|
|
Assert.True(TriggerPrompts.TryParseDuration("45 min", out d) && d == TimeSpan.FromMinutes(45));
|
|
Assert.False(TriggerPrompts.TryParseDuration("soon", out _));
|
|
Assert.False(TriggerPrompts.TryParseDuration("0m", out _));
|
|
Assert.False(TriggerPrompts.TryParseDuration("", out _));
|
|
|
|
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
|
Assert.True(TriggerPrompts.TryParseUntil("2026-10-05T18:00", berlin, out var until));
|
|
Assert.Equal(new DateTimeOffset(2026, 10, 5, 16, 0, 0, TimeSpan.Zero), until);
|
|
Assert.True(TriggerPrompts.TryParseUntil("2026-10-05T18:00Z", berlin, out until));
|
|
Assert.Equal(new DateTimeOffset(2026, 10, 5, 18, 0, 0, TimeSpan.Zero), until);
|
|
Assert.False(TriggerPrompts.TryParseUntil("tonight", berlin, out _));
|
|
}
|
|
}
|