forked from admin/VolcanicAgenticCore
- JobScheduler: a stored job whose zone lookup throws no longer fails StartAsync; timer re-armed under the lock; a delivery cut off by shutdown isn't recorded (one-offs fire again after restart); CancelJobAsync in one step - Schedules.ParseAt: ISO 8601 with a date only (no machine-clock date for a bare 09:00) - core: a FollowUp prompt that loses the race to a starting run joins it as a follow-up, not steering - Telegram: TimeZoneFor tolerates non-numeric targets; RunPromptAsync waits only when queued; one agent dir - ScriptRunner: bad workspace paths are Failed, not exceptions; zone id helpers share TimeZoneIds.ToIana's rule - tests: no wall-clock sleeps in scheduler tests; transcript-entry, disabled-scheduler and failing-prompt coverage - docs: spec notes for the above; README row for the scheduler
177 lines
7.8 KiB
C#
177 lines
7.8 KiB
C#
using Microsoft.Extensions.Time.Testing;
|
|
using Volcanic.AgenticCore.Extensions.Scheduler;
|
|
using Volcanic.AgenticCore.Telegram.Scheduling;
|
|
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
|
|
|
namespace Volcanic.AgenticCore.Telegram.Tests;
|
|
|
|
/// <summary>The scheduler through the Telegram delivery and commands, on a fake clock with a fake script runner.</summary>
|
|
public sealed class SchedulerChatTests
|
|
{
|
|
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
|
internal static readonly DateTimeOffset Now = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero); // Sat 14:03 Berlin
|
|
private const long OtherUser = 2002;
|
|
|
|
internal sealed class Runner : IScriptRunner
|
|
{
|
|
public int Calls;
|
|
public ScriptOutcome Outcome { get; set; } = new ScriptOutcome.Silent();
|
|
public Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken)
|
|
{
|
|
Interlocked.Increment(ref Calls);
|
|
return Task.FromResult(Outcome);
|
|
}
|
|
}
|
|
|
|
internal static async Task<(ChatHost Host, FakeTimeProvider Time)> StartedAsync(IScriptRunner? runner = null, Action<Microsoft.Extensions.DependencyInjection.IServiceCollection>? services = null)
|
|
{
|
|
var time = new FakeTimeProvider(Now);
|
|
var host = new ChatHost(core: o => o.DefaultTimeZone = Berlin, time: time, scheduler: new SchedulerOptions(), runner: runner);
|
|
host.Options.AllowedUserIds.Add(OtherUser);
|
|
await host.StartSchedulerAsync();
|
|
return (host, time);
|
|
}
|
|
|
|
internal static async Task AdvanceAsync(ChatHost host, FakeTimeProvider time, TimeSpan by)
|
|
{
|
|
time.Advance(by);
|
|
await host.Scheduler!.IdleAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_reminder_is_sent_to_its_chat_without_a_model_call_or_transcript_entry()
|
|
{
|
|
var (host, time) = await StartedAsync();
|
|
await using var _ = host;
|
|
var chat = await host.ChatAsync();
|
|
var entriesBefore = chat.Session.SessionManager.GetEntries().Count;
|
|
var leafBefore = chat.Session.SessionManager.LeafId;
|
|
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Dentist", JobKind.Reminder, At: "2026-09-26T14:05", Text: "Call the **dentist**"), CancellationToken.None);
|
|
|
|
await AdvanceAsync(host, time, TimeSpan.FromMinutes(2));
|
|
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
|
|
|
var sent = Assert.Single(host.Bot.Sent);
|
|
Assert.Equal(Updates.AllowedUser, sent.ChatId.Identifier);
|
|
Assert.Equal("⏰ Call the <b>dentist</b>", sent.Text);
|
|
Assert.Empty(host.Faux.Requests);
|
|
Assert.Empty(chat.Session.Messages);
|
|
Assert.Equal(entriesBefore, chat.Session.SessionManager.GetEntries().Count); // no entry of any kind
|
|
Assert.Equal(leafBefore, chat.Session.SessionManager.LeafId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_silent_watcher_with_a_fake_runner_creates_no_session_and_sends_nothing()
|
|
{
|
|
var runner = new Runner();
|
|
var (host, time) = await StartedAsync(runner);
|
|
await using var _ = host;
|
|
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Price", JobKind.Watcher, Cron: "* * * * *", Command: "scripts/price.sh"), CancellationToken.None);
|
|
|
|
for (var i = 0; i < 5; i++) await AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
|
|
|
|
Assert.Equal(5, runner.Calls);
|
|
Assert.Null(host.Chats.Find(Updates.AllowedUser)); // no session created or woken
|
|
Assert.Empty(host.Bot.Requests);
|
|
Assert.Empty(host.Faux.Requests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Delivery_WorkspaceFor_and_TimeZoneFor_never_create_a_session()
|
|
{
|
|
var (host, _) = await StartedAsync();
|
|
await using var __ = host;
|
|
await host.Settings.SetTimeZoneAsync(Updates.AllowedUser, "Asia/Tokyo", CancellationToken.None);
|
|
|
|
Assert.Equal(host.WorkspaceOf(), host.Delivery!.WorkspaceFor(ChatHost.Target));
|
|
Assert.Equal("Asia/Tokyo", host.Delivery.TimeZoneFor(ChatHost.Target).Id);
|
|
Assert.Same(host.Settings.DefaultZone, host.Delivery.TimeZoneFor("not-a-chat")); // a hand-edited store never throws here
|
|
Assert.Null(host.Chats.Find(Updates.AllowedUser));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IsDeliverable_follows_the_allow_list()
|
|
{
|
|
var (host, _) = await StartedAsync();
|
|
await using var __ = host;
|
|
Assert.True(host.Delivery!.IsDeliverable(ChatHost.Target));
|
|
Assert.False(host.Delivery.IsDeliverable(Updates.Stranger.ToString(System.Globalization.CultureInfo.InvariantCulture)));
|
|
Assert.False(host.Delivery.IsDeliverable("not-a-chat"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Jobs_lists_this_chats_jobs_in_its_zone()
|
|
{
|
|
var (host, _) = await StartedAsync();
|
|
await using var __ = host;
|
|
var once = await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Call the dentist", JobKind.Reminder, At: "2026-09-27T09:00", Text: "Call"), CancellationToken.None);
|
|
var cron = await host.Scheduler.CreateAsync(ChatHost.Target, new NewJob("Inbox summary", JobKind.Prompt, Cron: "0 8 * * 1-5", Text: "Summarise"), CancellationToken.None);
|
|
|
|
await host.SendAsync("/jobs");
|
|
|
|
Assert.Equal(
|
|
$"Scheduled jobs (Europe/Berlin, now 26 Sep 14:03)\n{once.Job.Id} ⏰ Call the dentist · once 27 Sep 09:00\n{cron.Job.Id} 💬 Inbox summary · 0 8 * * 1-5 · next 28 Sep 08:00",
|
|
host.LastReply());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Jobs_without_jobs_says_so()
|
|
{
|
|
var (host, _) = await StartedAsync();
|
|
await using var __ = host;
|
|
await host.SendAsync("/jobs");
|
|
Assert.Equal("No scheduled jobs.", host.LastReply());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cancel_handles_own_unknown_and_other_chats_ids()
|
|
{
|
|
var (host, _) = await StartedAsync();
|
|
await using var __ = host;
|
|
var mine = (await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Call the dentist", JobKind.Reminder, Cron: "@daily", Text: "x"), CancellationToken.None)).Job.Id;
|
|
var theirs = (await host.Scheduler.CreateAsync("2002", new NewJob("Theirs", JobKind.Reminder, Cron: "@daily", Text: "y"), CancellationToken.None)).Job.Id;
|
|
|
|
await host.SendAsync($"/cancel {theirs}");
|
|
Assert.Equal($"No job {theirs} in this chat.", host.LastReply());
|
|
await host.SendAsync("/cancel zzzzz");
|
|
Assert.Equal("No job zzzzz in this chat.", host.LastReply());
|
|
await host.SendAsync($"/cancel {mine.ToUpperInvariant()}");
|
|
Assert.Equal($"Cancelled {mine} (Call the dentist).", host.LastReply());
|
|
|
|
Assert.Empty(host.Scheduler.List(ChatHost.Target));
|
|
Assert.Single(host.Scheduler.List("2002"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cancel_without_an_id_shows_usage()
|
|
{
|
|
var (host, _) = await StartedAsync();
|
|
await using var __ = host;
|
|
await host.SendAsync("/cancel");
|
|
Assert.Equal("Usage: /cancel <id> (see /jobs).", host.LastReply());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Status_shows_the_job_count()
|
|
{
|
|
var (host, _) = await StartedAsync();
|
|
await using var __ = host;
|
|
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("a", JobKind.Reminder, Cron: "@daily", Text: "x"), CancellationToken.None);
|
|
await host.SendAsync("/status");
|
|
Assert.Contains("Jobs: 1\n", host.LastReply());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Without_a_scheduler_jobs_says_scheduling_is_off()
|
|
{
|
|
await using var host = new ChatHost();
|
|
await host.SendAsync("/jobs");
|
|
Assert.Equal(SchedulerSetup.DisabledReply, host.LastReply());
|
|
await host.SendAsync("/cancel abcde");
|
|
Assert.Equal(SchedulerSetup.DisabledReply, host.LastReply());
|
|
await host.SendAsync("/status");
|
|
Assert.DoesNotContain("Jobs:", host.LastReply());
|
|
Assert.Contains("Time zone:", host.LastReply());
|
|
}
|
|
}
|