Jobs are prompts or watchers. A reminder is a prompt the agent passes on, so
it lands in the transcript. Stored reminder jobs load as prompts
("Remind me: <text>"). Also records the Coolify deploy target in CLAUDE.md.
206 lines
11 KiB
C#
206 lines
11 KiB
C#
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 = "prompt", cron = "0 * * * *", text = "Remind the user to 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.Prompt, At: "2026-09-26T15:03", Text: "Call the dentist"), CancellationToken.None);
|
|
await first.Scheduler.CreateAsync(ChatHost.Target, new NewJob("Hourly", JobKind.Prompt, 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());
|
|
second.Faux.Enqueue(FauxTurn.Say("Time to call the dentist."));
|
|
await second.StartSchedulerAsync();
|
|
await second.Scheduler!.IdleAsync();
|
|
await second.SettleAsync();
|
|
|
|
Assert.EndsWith("This was due at 26 Sep 15:03; the bot was offline.\nCall the dentist", Assert.Single(second.Faux.Requests).Messages.Last().Text);
|
|
Assert.Contains(second.Texts, t => t.Contains("Time to call the dentist.", StringComparison.Ordinal));
|
|
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 A_scheduled_prompt_that_fails_is_reported_to_the_chat_and_the_job_still_completes()
|
|
{
|
|
var (host, time) = await SchedulerChatTests.StartedAsync();
|
|
await using var _ = host;
|
|
var chat = await host.ChatAsync();
|
|
await chat.Session.DisposeAsync(); // PromptAsync now throws, as any failure to start the run would
|
|
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Check", JobKind.Prompt, At: BerlinAt(time, 1), Text: "Check the build"), CancellationToken.None);
|
|
|
|
await SchedulerChatTests.AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
|
|
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
|
|
|
Assert.Contains(host.Texts, t => t.Contains("The scheduled job failed:", StringComparison.Ordinal));
|
|
Assert.Empty(host.Faux.Requests);
|
|
Assert.Empty(host.Scheduler.List(ChatHost.Target)); // the one-off completed (no redelivery queue)
|
|
}
|
|
|
|
[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.Prompt, Cron: "0 9 * * *", Text: "Good morning"), CancellationToken.None);
|
|
await host.Scheduler.CreateAsync(ChatHost.Target, new NewJob("Dentist", JobKind.Prompt, 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);
|
|
}
|
|
}
|