test(telegram): scheduled prompts, watcher matches, restart and zone changes end to end

This commit is contained in:
EugeneTes
2026-09-27 10:36:27 +02:00
parent 8dd874a83f
commit 709b2d2513

View File

@@ -0,0 +1,186 @@
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Tests.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests;
/// <summary>Scheduled prompts and watcher matches reach the agent; everything else stays out of the model and the transcript.</summary>
public sealed class ScheduledPromptChatTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private static readonly DateTimeOffset Now = SchedulerChatTests.Now; // Sat 26 Sep 14:03 Berlin
private const long OtherUser = 2002;
/// <summary>"yyyy-MM-ddTHH:mm" in Berlin, <paramref name="minutes"/> after the fake clock's now.</summary>
private static string BerlinAt(FakeTimeProvider time, int minutes) =>
TimeZoneInfo.ConvertTime(time.GetUtcNow().AddMinutes(minutes), Berlin).ToString("yyyy-MM-ddTHH:mm", System.Globalization.CultureInfo.InvariantCulture);
[Fact]
public async Task A_prompt_job_on_an_idle_chat_renders_the_reply_with_the_timestamp_header()
{
var (host, time) = await SchedulerChatTests.StartedAsync();
await using var _ = host;
var job = (await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Inbox summary", JobKind.Prompt, At: BerlinAt(time, 2), Text: "Summarise inbox/"), CancellationToken.None)).Job;
host.Faux.Enqueue(FauxTurn.Say("Your inbox is empty."));
await SchedulerChatTests.AdvanceAsync(host, time, TimeSpan.FromMinutes(2));
await host.SettleAsync();
Assert.Contains(host.Texts, t => t.Contains("Your inbox is empty.", StringComparison.Ordinal));
Assert.Equal(
$"{MessageContext.TimestampHeader(Now.AddMinutes(2), Berlin)}\n[Scheduled job \"Inbox summary\" ({job.Id})]\nSummarise inbox/",
Assert.Single(host.Faux.Requests).Messages.Last().Text);
}
[Fact]
public async Task A_prompt_job_during_a_user_run_is_queued_as_a_follow_up_and_answered_after_the_user()
{
var started = new TaskCompletionSource();
var gate = new TaskCompletionSource();
var time = new FakeTimeProvider(Now);
await using var host = new ChatHost(core: o => { o.DefaultTimeZone = Berlin; o.AddExtension(new GateToolExtension(started, gate)); },
time: time, scheduler: new SchedulerOptions());
await host.StartSchedulerAsync();
host.Faux.Enqueue(FauxTurn.Tool(GateToolExtension.ToolName, new { }), FauxTurn.Say("User answer."), FauxTurn.Say("Job answer."));
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Check", JobKind.Prompt, At: BerlinAt(time, 1), Text: "Check the build"), CancellationToken.None);
var chat = await host.ChatAsync();
var queues = new List<AgentSessionEvent.QueueUpdate>();
using var subscription = chat.Session.Subscribe(e => { if (e is AgentSessionEvent.QueueUpdate q) lock (queues) queues.Add(q); });
await host.SendAsync("run the long thing");
await MessageContextChatTests.FlushAsync(host, time, parts: 1);
await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
time.Advance(TimeSpan.FromMinutes(1)); // the job fires while the user's run is active
await Wait.UntilAsync(() => { lock (queues) return queues.Any(q => q.FollowUp.Count == 1 && q.Steering.Count == 0); }, "the job's follow-up");
gate.SetResult();
await host.Scheduler.IdleAsync();
await host.SettleAsync();
var texts = host.Texts.ToList();
var user = texts.FindIndex(t => t.Contains("User answer.", StringComparison.Ordinal));
var job = texts.FindIndex(t => t.Contains("Job answer.", StringComparison.Ordinal));
Assert.True(user >= 0 && job > user, string.Join(" | ", texts));
lock (queues) Assert.DoesNotContain(queues, q => q.Steering.Count > 0);
}
[Fact]
public async Task A_watcher_over_a_real_script_stays_silent_until_it_prints()
{
if (OperatingSystem.IsWindows()) return;
var time = new FakeTimeProvider(Now);
await using var host = new ChatHost(core: o => o.DefaultTimeZone = Berlin, time: time, scheduler: new SchedulerOptions()); // real ScriptRunner
await host.StartSchedulerAsync();
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Price", JobKind.Watcher, Cron: "* * * * *", Command: "cat match.txt 2>/dev/null; exit 0"), CancellationToken.None);
for (var i = 0; i < 3; i++) await SchedulerChatTests.AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
Assert.Empty(host.Faux.Requests);
Assert.Null(host.Chats.Find(Updates.AllowedUser));
Assert.Empty(host.Bot.Sent);
await File.WriteAllTextAsync(Path.Combine(host.WorkspaceOf(), "match.txt"), "price 48\n");
host.Faux.Enqueue(FauxTurn.Say("The price dropped to 48."));
await SchedulerChatTests.AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
await host.SettleAsync();
var request = Assert.Single(host.Faux.Requests);
Assert.EndsWith("The watcher script printed:\nprice 48", request.Messages.Last().Text);
Assert.Contains(host.Texts, t => t.Contains("The price dropped to 48.", StringComparison.Ordinal));
}
[Fact]
public async Task A_schedule_tool_call_creates_a_job_for_its_own_chat_only()
{
var (host, time) = await SchedulerChatTests.StartedAsync();
await using var _ = host;
host.Faux.Enqueue(
FauxTurn.Tool("schedule", new { action = "create", name = "Stretch", kind = "reminder", cron = "0 * * * *", text = "Stretch!" }),
FauxTurn.Say("Scheduled."));
var chat = await host.ChatAsync();
await chat.Session.PromptAsync("remind me to stretch every hour");
await host.SendAsync("/jobs", userId: OtherUser);
Assert.Equal("Stretch", Assert.Single(host.Scheduler!.List(ChatHost.Target)).Job.Name);
Assert.Equal("No scheduled jobs.", host.LastReply(OtherUser));
await host.SendAsync("/jobs");
Assert.Contains("⏰ Stretch · 0 * * * * · next 15:00", host.LastReply());
}
[Fact]
public async Task Restart_delivers_an_overdue_one_off_late_and_does_not_catch_up_cron()
{
var root = Directory.CreateTempSubdirectory("scheduler-restart-").FullName;
try
{
var before = new FakeTimeProvider(Now);
await using (var first = new ChatHost(core: o => o.DefaultTimeZone = Berlin, time: before, root: root, scheduler: new SchedulerOptions()))
{
await first.StartSchedulerAsync();
await first.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Dentist", JobKind.Reminder, At: "2026-09-26T15:03", Text: "Call the dentist"), CancellationToken.None);
await first.Scheduler.CreateAsync(ChatHost.Target, new NewJob("Hourly", JobKind.Reminder, Cron: "0 * * * *", Text: "Hourly ping"), CancellationToken.None);
}
var after = new FakeTimeProvider(Now.AddHours(3)); // the bot was down from 14:03 to 17:03
await using var second = new ChatHost(core: o => o.DefaultTimeZone = Berlin, time: after, root: root, scheduler: new SchedulerOptions());
await second.StartSchedulerAsync();
await second.Scheduler!.IdleAsync();
await (await second.ChatAsync()).Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal("⏰ Call the dentist (due 26 Sep 15:03, delivered late)", Assert.Single(second.Bot.Sent).Text);
var hourly = Assert.Single(second.Scheduler.List(ChatHost.Target));
Assert.Equal(new DateTimeOffset(2026, 9, 26, 16, 0, 0, TimeSpan.Zero), hourly.NextRunAt); // 18:00 Berlin, not caught up
}
finally { Directory.Delete(root, recursive: true); }
}
[Fact]
public async Task A_scheduled_prompt_does_not_answer_a_pending_login_code_prompt()
{
var (host, time) = await SchedulerChatTests.StartedAsync();
await using var _ = host;
var ui = await new OwnerLoginChannel(host.Chats, host.Options).ResolveAsync(CancellationToken.None);
var ask = new UiLoginPrompt(ui!).AskForCodeAsync("https://claude.com/cai/oauth/authorize?state=t", 1, CancellationToken.None);
var chat = await host.ChatAsync();
await Wait.UntilAsync(() => chat.Ui.IsWaitingForInput, "the login code question");
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Check", JobKind.Prompt, At: BerlinAt(time, 1), Text: "Check the build"), CancellationToken.None);
host.Faux.Enqueue(FauxTurn.Say("Build is green."));
await SchedulerChatTests.AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
await host.SettleAsync();
Assert.False(ask.IsCompleted);
Assert.True(chat.Ui.IsWaitingForInput);
Assert.Contains("[Scheduled job \"Check\"", Assert.Single(host.Faux.Requests).Messages.Last().Text);
await host.SendAsync("pasted-code");
Assert.Equal("pasted-code", await ask.WaitAsync(TimeSpan.FromSeconds(5)));
}
[Fact]
public async Task Timezone_with_jobs_reports_counts_and_jobs_shows_tokyo_times()
{
var (host, _) = await SchedulerChatTests.StartedAsync();
await using var __ = host;
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Morning", JobKind.Reminder, Cron: "0 9 * * *", Text: "Good morning"), CancellationToken.None);
await host.Scheduler.CreateAsync(ChatHost.Target, new NewJob("Dentist", JobKind.Reminder, At: "2026-09-27T09:00", Text: "Dentist"), CancellationToken.None);
await host.SendAsync("/timezone Asia/Tokyo");
Assert.Equal("Time zone set to Asia/Tokyo (UTC+09:00). Now 26 Sep 21:03. 1 recurring job now follows this zone; 1 one-off job keeps its time (now 27 Sep 16:00 here).",
host.LastReply());
Assert.Equal("Asia/Tokyo", host.Settings.TimeZoneFor(Updates.AllowedUser).Id);
await host.SendAsync("/jobs");
var jobs = host.LastReply();
Assert.StartsWith("Scheduled jobs (Asia/Tokyo, now 26 Sep 21:03)", jobs);
Assert.Contains("Morning · 0 9 * * * · next 27 Sep 09:00", jobs);
Assert.Contains("Dentist · once 27 Sep 16:00", jobs);
}
}