refactor(scheduler): drop the reminder job kind

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.
This commit is contained in:
EugeneTes
2026-09-29 09:30:40 +00:00
parent c3a0cf234c
commit 8014a0e770
22 changed files with 164 additions and 146 deletions

View File

@@ -119,6 +119,12 @@ through the chat's `IChatChannel`, prompts as session follow-ups with the timest
and `SchedulerHostedService` starts the scheduler; `/jobs` and `/cancel` list and cancel a chat's jobs.
Answers go out complete, not streamed (`Rendering/`). Tests drive the host through `Support/FakeBotClient` and `ChatHost`.
## Deployment
Deploy to the Coolify app `volcanic-secretary-telegram` (uuid `r8b5tgty5v5svat9rnnojahb`, builds `main`) through the
Coolify API after pushing: `POST $COOLIFY_URL/api/v1/deploy?uuid=r8b5tgty5v5svat9rnnojahb`. Container setup:
`docs/docker.md`.
## Conventions
- Commits follow Conventional Commits scoped by area: `feat(claude-code): ...`, `fix(telegram): ...`, `docs(spec): ...`,

View File

@@ -18,7 +18,7 @@ Claude Code as a model provider without an API key.
| OpenAI-compatible provider | `extensions/Volcanic.AgenticCore.Extensions.OpenAI` | OpenAI, OpenRouter, Groq, Ollama, llama.cpp, vLLM |
| Claude Code (subscription) provider | `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode` (+ `.Bridge`) | Spawns `claude -p`, MCP "proposal" bridge |
| Google Calendar (tool + connect commands) | `extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar` | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`; one account per chat |
| Scheduler (reminders, scheduled prompts, watcher scripts) | `extensions/Volcanic.AgenticCore.Extensions.Scheduler` | Cronos; per-chat jobs in `<agent dir>/scheduler/jobs.json`; see [docs/telegram-bot.md](docs/telegram-bot.md#scheduling) |
| Scheduler (scheduled prompts, watcher scripts) | `extensions/Volcanic.AgenticCore.Extensions.Scheduler` | Cronos; per-chat jobs in `<agent dir>/scheduler/jobs.json`; see [docs/telegram-bot.md](docs/telegram-bot.md#scheduling) |
| Telegram bot | `hosts/Volcanic.AgenticCore.Telegram` | Chat with the agent from Telegram; long polling, Claude Code, allow-list |
| Reference headless host | `samples/Volcanic.AgenticCore.Sample.Headless` | Prompt in, JSON events out |
| Tests | `tests/` | xunit; no test calls a paid API |

View File

@@ -21,8 +21,8 @@ directories.
`GoogleCalendar` is a feature extension: a tool and three commands, per-chat accounts through process-wide
`GoogleCalendarConnections` (see [google-calendar.md](google-calendar.md)).
`Scheduler` is a feature extension with a process-wide `JobScheduler` (reminders, scheduled prompts and watcher
scripts on a `TimeProvider` timer, jobs in `<AgentDir>/scheduler/jobs.json`), a per-session `schedule` tool bound to a
`Scheduler` is a feature extension with a process-wide `JobScheduler` (scheduled prompts and watcher scripts
on a `TimeProvider` timer, jobs in `<AgentDir>/scheduler/jobs.json`), a per-session `schedule` tool bound to a
host target, and an `IJobDelivery` the host implements (see [telegram-bot.md](telegram-bot.md#scheduling)).
## Vertical slices

View File

@@ -1,12 +1,21 @@
# Scheduler: reminders, scheduled prompts and watcher scripts (design)
Date: 2026-09-26
Status: implemented
Status: implemented; the `reminder` kind was removed on 2026-09-29 (see "Update" below)
Depends on: `2026-09-26-telegram-attachments-design.md` (`IChatChannel`, `ChatContext.Channel`, the renderer's
`RunInOrderAsync`); `2026-09-26-process-environment-hook-design.md` (`IProcessSpawnPolicy`, which builds the watcher
scripts' environment, and `ISecretRedactor`, which cleans their output); `2026-09-26-telegram-message-context-design.md`
(`MessageContext.TimestampHeader`, the local-time header on every prompt).
## Update (2026-09-29): prompts and watchers only
The `reminder` kind is gone: a reminder is a `prompt` job whose text tells the agent what to remind the user of, so
the agent writes it and it lands in the transcript like any other scheduled prompt. `JobKind` is `Prompt | Watcher`,
the `schedule` tool accepts only `prompt` and `watcher`, and `IJobDelivery.SendTextAsync` is used only for watcher
failure alerts. `JobStore` loads a stored `"kind": "reminder"` entry as a prompt with the text `Remind me: <text>`
(the file is rewritten in the new form on the next save). The rest of this document describes the original
three-kind design.
## Problem
The Telegram bot only speaks when spoken to. The owner wants it to act on a clock: "remind me at 9 tomorrow to call

View File

@@ -35,7 +35,7 @@ settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section
| `Telegram:InputDebounce` | `00:00:00.6` | How long the bot waits after a message for more before sending them to the agent as one request. `0` disables the wait; messages still being read together (e.g. a photo still downloading and a text after it) are still sent as one request. Must not be negative |
| `Telegram:ReconnectInitialDelay` / `ReconnectMaxDelay` | `1 s` / `60 s` | Backoff after lost connection |
| `DEFAULT_TIMEZONE` / `Telegram:DefaultTimeZone` | — (the machine's zone) | IANA zone for chats that haven't set one with `/timezone`, e.g. `Europe/Berlin`. An unknown name stops startup. Also the default for the core (`AgenticCoreOptions.DefaultTimeZone`) |
| `Scheduler:Enabled` | `true` | Reminders, scheduled prompts and watcher scripts (see [Scheduling](#scheduling)) |
| `Scheduler:Enabled` | `true` | Scheduled prompts and watcher scripts (see [Scheduling](#scheduling)) |
| `Scheduler:StorePath` | `<agent dir>/scheduler/jobs.json` | One file for every chat's jobs, outside the workspaces |
| `Scheduler:DefaultScriptTimeout` | `00:05:00` | Watcher timeout when a job sets none (1 s to 1 h) |
| `Scheduler:MaxJobsPerTarget` | `50` | Jobs per chat |
@@ -147,12 +147,11 @@ chat's workspace and delivers only to that chat.
### Scheduling
Ask the agent in plain words. It has a `schedule` tool with three kinds of job:
Ask the agent in plain words. It has a `schedule` tool with two kinds of job:
- "Remind me at 9 tomorrow to call the dentist" is a **reminder**: at 9 the bot sends the text as is,
`⏰ Call the dentist`, with no model call.
- "Every weekday at 8, summarise what's in `inbox/`" is a **prompt**: when it fires, the agent handles it like a
message from you, with the same time header.
- "Remind me at 9 tomorrow to call the dentist" or "Every weekday at 8, summarise what's in `inbox/`" is a
**prompt**: when it fires, the agent handles it like a message from you, with the same time header, and writes the
reminder or the summary.
- "Check this page every 10 minutes and tell me only if the price drops below 50" is a **watcher**.
For a watcher the agent writes a script (usually in `scripts/`, tested with `bash` first). Every run the bot runs the
@@ -175,12 +174,12 @@ tool) changes it for the date in the agent's instructions, `bash`, scheduled scr
Code. Recurring jobs follow the new zone ("every day at 9" means 9 where you are now); one-off jobs keep their moment
and are just shown in the new zone. The reply says how many of each.
After a restart, a one-off that came due while the bot was down is delivered as soon as it's back, marked
`(due …, delivered late)` (for prompts: "This was due at …; the bot was offline."). Recurring jobs don't catch up:
After a restart, a one-off that came due while the bot was down is delivered as soon as it's back, with
"This was due at …; the bot was offline." in the prompt. Recurring jobs don't catch up:
missed occurrences are skipped.
Jobs are per chat: `/jobs` and the agent only see this chat's jobs. Direct reminders and failure alerts aren't part
of the conversation, so the agent can't see them later; use a prompt job for things that should be. With
Jobs are per chat: `/jobs` and the agent only see this chat's jobs. Watcher failure alerts aren't part of the
conversation, so the agent can't see them later. With
`Scheduler:Enabled=false`, `/jobs` and `/cancel` reply that scheduling is turned off, and `/timezone` still works.
### Google Calendar

View File

@@ -192,7 +192,7 @@ public sealed class ChatDeliveryException(string message, Exception? inner = nul
- `Limits`: your platform's upload limits (Telegram: photo 10 MB, document 50 MB, caption 1024 characters). `send`
checks them before calling you, and its errors name them.
- `SendTextAsync`: markdown, rendered like assistant text and split when long. It is not added to the session
transcript, and it must work while no run is active: schedulers use it for reminders.
transcript, and it must work while no run is active: schedulers use it for failure alerts.
- `SendFileAsync`: the core has already confined the path to the workspace (symlinks included), checked that the file
exists and applied the limits. `Auto` lets you choose, and you may fall back (Telegram resends a rejected photo as a
document); report what you did in `SentAs`. Throw only `ChatDeliveryException`, with a message the model or the

View File

@@ -6,7 +6,7 @@ namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>How jobs and times are shown (the <c>schedule</c> tool, <c>/jobs</c>, zone-change replies). Invariant culture, 24 h.</summary>
public static class JobFormat
{
public static string Icon(JobKind kind) => kind switch { JobKind.Reminder => "⏰", JobKind.Prompt => "💬", _ => "🔍" };
public static string Icon(JobKind kind) => kind == JobKind.Prompt ? "💬" : "🔍";
/// <summary><c>27 Sep 09:00</c> in <paramref name="zone"/>.</summary>
public static string DayTime(DateTimeOffset at, TimeZoneInfo zone) =>
@@ -36,7 +36,7 @@ public static class JobFormat
_ => "did not start",
};
/// <summary><c>`k7m2q` ⏰ Call the dentist · once 27 Sep 09:00</c>; cron jobs show the expression and the next run; a failed last run is appended.</summary>
/// <summary><c>`k7m2q` 💬 Call the dentist · once 27 Sep 09:00</c>; cron jobs show the expression and the next run; a failed last run is appended.</summary>
public static string Line(JobView view, TimeZoneInfo zone, DateTimeOffset now)
{
var job = view.Job;

View File

@@ -3,9 +3,6 @@ namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>The texts a fire sends (spec §4 table).</summary>
internal static class JobMessages
{
public static string Reminder(ScheduledJob job, DateTimeOffset? lateDue, TimeZoneInfo zone) =>
$"⏰ {job.Text}" + (lateDue is { } due ? $" (due {JobFormat.DayTime(due, zone)}, delivered late)" : "");
public static string Prompt(ScheduledJob job, DateTimeOffset? lateDue, TimeZoneInfo zone) =>
$"{Header(job)}\n" + (lateDue is { } due ? $"This was due at {JobFormat.DayTime(due, zone)}; the bot was offline.\n" : "") + job.Text;

View File

@@ -347,11 +347,6 @@ public sealed class JobScheduler : IAsyncDisposable
JobResult? result;
switch (job.Kind)
{
case JobKind.Reminder:
result = await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Reminder(job, lateDue, zone), ct)).ConfigureAwait(false)
? new JobResult(true, null, false, null, _time.GetUtcNow())
: null;
break;
case JobKind.Prompt:
result = await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.Prompt(job, lateDue, zone), ct)).ConfigureAwait(false)
? new JobResult(true, null, false, null, _time.GetUtcNow())

View File

@@ -1,5 +1,6 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Json;
@@ -8,11 +9,13 @@ namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>
/// <c>jobs.json</c>: <c>{ "version": 1, "jobs": [...] }</c> in <see cref="VolcanicJson"/> camelCase. Written atomically
/// (temp file + rename) through one semaphore. A corrupt file is set aside and loading starts empty, so a bad file never
/// keeps the host down; invalid jobs (including entries that don't deserialise) are dropped with a warning.
/// keeps the host down; invalid jobs (including entries that don't deserialise) are dropped with a warning. A job of the
/// retired <c>reminder</c> kind is loaded as a prompt asking the agent to pass the reminder on.
/// </summary>
public sealed class JobStore(string path, TimeProvider time, ILogger logger)
{
public const int Version = 1;
private const string ReminderPrefix = "Remind me: ";
private readonly SemaphoreSlim _writeGate = new(1, 1);
public string Path => path;
@@ -83,7 +86,7 @@ public sealed class JobStore(string path, TimeProvider time, ILogger logger)
ScheduledJob? job;
try
{
job = VolcanicJson.Deserialize<ScheduledJob>(entry);
job = VolcanicJson.Deserialize<ScheduledJob>(FromReminder(entry));
}
catch (JsonException ex)
{
@@ -93,6 +96,20 @@ public sealed class JobStore(string path, TimeProvider time, ILogger logger)
return (job, JobValidation.CheckStored(job));
}
/// <summary>A stored <c>reminder</c> (fixed text sent as is) becomes a prompt; other entries pass through unchanged.</summary>
private static JsonElement FromReminder(JsonElement entry)
{
if (!entry.TryGetProperty("kind", out var kind) || kind.ValueKind != JsonValueKind.String
|| !string.Equals(kind.GetString(), "reminder", StringComparison.OrdinalIgnoreCase))
return entry;
var node = JsonNode.Parse(entry.GetRawText())!.AsObject();
node["kind"] = "prompt";
if (entry.TryGetProperty("text", out var text) && text.ValueKind == JsonValueKind.String && text.GetString() is { } body && !string.IsNullOrWhiteSpace(body)
&& body.Length + ReminderPrefix.Length <= JobValidation.MaxTextLength)
node["text"] = ReminderPrefix + body;
return JsonSerializer.SerializeToElement(node);
}
private sealed record JobFile(int Version, List<ScheduledJob> Jobs);
private sealed record StoredFile(int Version, List<JsonElement>? Jobs);

View File

@@ -61,12 +61,11 @@ internal static class JobValidation
var hasCommand = !string.IsNullOrWhiteSpace(command);
switch (kind)
{
case JobKind.Reminder when !hasText: throw new JobValidationException("A reminder needs text (the message to send).");
case JobKind.Prompt when !hasText: throw new JobValidationException("A prompt job needs text (what to ask the agent).");
case JobKind.Reminder or JobKind.Prompt when hasCommand: throw new JobValidationException("command is only for watchers.");
case JobKind.Prompt when hasCommand: throw new JobValidationException("command is only for watchers.");
case JobKind.Watcher when !hasCommand: throw new JobValidationException("A watcher needs command (the shell command to run).");
case JobKind.Watcher when hasText: throw new JobValidationException("A watcher takes no text: its script's output becomes the prompt.");
case not (JobKind.Reminder or JobKind.Prompt or JobKind.Watcher): throw new JobValidationException("kind must be reminder, prompt or watcher.");
case not (JobKind.Prompt or JobKind.Watcher): throw new JobValidationException("kind must be prompt or watcher.");
}
if (hasText && text!.Length > MaxTextLength) throw new JobValidationException($"text is {Count(text.Length)} characters; the limit is {Count(MaxTextLength)}.");
if (timeoutSeconds is { } seconds)

View File

@@ -1,7 +1,7 @@
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>Reminder: fixed text to the chat, no model call. Prompt: fixed text through the agent. Watcher: a script whose output goes to the agent.</summary>
public enum JobKind { Reminder, Prompt, Watcher }
/// <summary>Prompt: fixed text through the agent. Watcher: a script whose output goes to the agent.</summary>
public enum JobKind { Prompt, Watcher }
/// <summary>Exactly one is set. <see cref="At"/> is an absolute instant; <see cref="Cron"/> is 5-field wall-clock time in the target's zone.</summary>
public sealed record JobSchedule(DateTimeOffset? At, string? Cron);
@@ -18,7 +18,7 @@ public sealed record ScheduledJob
public required string Name { get; init; }
public required JobKind Kind { get; init; }
public required JobSchedule Schedule { get; init; }
/// <summary>Reminder and prompt only.</summary>
/// <summary>Prompt only.</summary>
public string? Text { get; init; }
/// <summary>Watcher only.</summary>
public string? Command { get; init; }

View File

@@ -8,10 +8,10 @@ public sealed record ScheduleArgs(
[property: Description("create, list or cancel")] string Action,
[property: Description("cancel: the job id (see list)")] string? Id = null,
[property: Description("create: a short label, e.g. \"Call the dentist\"")] string? Name = null,
[property: Description("create: reminder (fixed text sent as is), prompt (text you handle when it fires) or watcher (a script; you get its output when it prints something)")] string? Kind = null,
[property: Description("create: prompt (text you handle when it fires, e.g. a reminder you pass on) or watcher (a script; you get its output when it prints something)")] string? Kind = null,
[property: Description("create, one-off: ISO 8601 date-time; without an offset it's the chat's local time")] string? At = null,
[property: Description("create, recurring: 5-field cron in the chat's zone (minute hour day-of-month month day-of-week), e.g. \"0 8 * * 1-5\"")] string? Cron = null,
[property: Description("create, reminder/prompt: the text (at most 4000 characters)")] string? Text = null,
[property: Description("create, prompt: the text (at most 4000 characters)")] string? Text = null,
[property: Description("create, watcher: shell command run from the workspace")] string? Command = null,
[property: Description("create, watcher: timeout in seconds, 1-3600 (default 300)")] int? TimeoutSeconds = null);
@@ -23,14 +23,14 @@ public sealed class ScheduleTool(JobScheduler scheduler, string target) : Tool<S
public override string Name => ToolName;
public override string Description =>
"Create, list or cancel this chat's scheduled jobs: one-off or recurring reminders (fixed text), prompts (you handle them when they fire) and watcher scripts (run without you; their output comes to you only when they print something).";
"Create, list or cancel this chat's scheduled jobs: one-off or recurring prompts (you handle them when they fire, e.g. to remind the user) and watcher scripts (run without you; their output comes to you only when they print something).";
public override string? PromptSnippet => "schedule: reminders, recurring prompts and watcher scripts that message this chat";
public override string? PromptSnippet => "schedule: one-off and recurring prompts and watcher scripts that message this chat";
public override IReadOnlyList<string> PromptGuidelines =>
[
"schedule: times are in the chat's time zone (see the environment section); at is ISO 8601, cron has 5 fields (minute hour day-of-month month day-of-week).",
"schedule: use kind reminder for fixed text and prompt when the job needs you to think or use tools.",
"schedule: for a reminder use kind prompt with text telling you what to remind the user of; when it fires, write the reminder yourself.",
"schedule: a watcher's command runs with the same shell as bash, from the workspace, without you. It should print nothing and exit 0 while the condition isn't met (that costs nothing), and print the facts only when it is met: the output comes back to you as a prompt and you write the message. Exit non-zero only on a real error.",
"schedule: put reusable scripts in scripts/ and test them with bash before scheduling.",
];
@@ -61,10 +61,9 @@ public sealed class ScheduleTool(JobScheduler scheduler, string target) : Tool<S
{
var kind = (args.Kind ?? "").Trim().ToLowerInvariant() switch
{
"reminder" => JobKind.Reminder,
"prompt" => JobKind.Prompt,
"watcher" => JobKind.Watcher,
_ => throw new JobValidationException("kind must be reminder, prompt or watcher."),
_ => throw new JobValidationException("kind must be prompt or watcher."),
};
var view = await scheduler.CreateAsync(target, new NewJob(args.Name ?? "", kind, args.At, args.Cron, args.Text, args.Command, args.TimeoutSeconds), cancellationToken).ConfigureAwait(false);
var zone = scheduler.TimeZoneFor(target);

View File

@@ -11,11 +11,11 @@ public static class SchedulerSetup
{
public const string DisabledReply = "Scheduling is turned off on this bot (Scheduler:Enabled).";
public const string HelpLines = "\n/jobs – this chat's scheduled reminders, prompts and watchers\n/cancel <id> – cancel one of them";
public const string HelpLines = "\n/jobs – this chat's scheduled prompts and watchers\n/cancel <id> – cancel one of them";
public static readonly IReadOnlyList<BotCommand> Menu =
[
new() { Command = "jobs", Description = "Scheduled reminders, prompts and watchers" },
new() { Command = "jobs", Description = "Scheduled prompts and watchers" },
new() { Command = "cancel", Description = "Cancel a scheduled job: /cancel <id>" },
];

View File

@@ -18,8 +18,8 @@ public sealed class JobFormatTests
public void Job_lines_show_kind_schedule_next_and_last_failure()
{
var once = new DateTimeOffset(2026, 9, 27, 7, 0, 0, TimeSpan.Zero);
Assert.Equal("`k7m2q` ⏰ Call the dentist · once 27 Sep 09:00",
JobFormat.Line(new JobView(Job("k7m2q", "Call the dentist", JobKind.Reminder, new JobSchedule(once, null)), once), Berlin, Now));
Assert.Equal("`k7m2q` 💬 Call the dentist · once 27 Sep 09:00",
JobFormat.Line(new JobView(Job("k7m2q", "Call the dentist", JobKind.Prompt, new JobSchedule(once, null)), once), Berlin, Now));
Assert.Equal("`p3xz9` 💬 Inbox summary · `0 8 * * 1-5` · next 28 Sep 08:00",
JobFormat.Line(new JobView(Job("p3xz9", "Inbox summary", JobKind.Prompt, new JobSchedule(null, "0 8 * * 1-5")), new DateTimeOffset(2026, 9, 28, 6, 0, 0, TimeSpan.Zero)), Berlin, Now));
var failing = Job("w8r4t", "Price check", JobKind.Watcher, new JobSchedule(null, "*/10 * * * *")) with { LastResult = new JobResult(false, 1, false, "x", Now) };

View File

@@ -21,17 +21,17 @@ public sealed class JobSchedulerTests
public async Task An_overdue_one_off_fires_on_start_with_the_late_note_and_is_removed_after_delivery()
{
await using var h = new SchedulerHarness();
await h.Store.SaveAsync([Stored("k7m2q", JobKind.Reminder, new JobSchedule(Start.AddMinutes(-5), null))], CancellationToken.None);
h.Delivery.HoldTexts = new TaskCompletionSource();
await h.Store.SaveAsync([Stored("k7m2q", JobKind.Prompt, new JobSchedule(Start.AddMinutes(-5), null))], CancellationToken.None);
h.Delivery.HoldPrompts = new TaskCompletionSource();
await h.StartAsync();
await Eventually.UntilAsync(() => h.Delivery.Texts.Count == 1, "the reminder send");
await Eventually.UntilAsync(() => h.Delivery.Prompts.Count == 1, "the prompt run");
Assert.Single(await h.Store.LoadAsync(CancellationToken.None)); // not removed while the delivery is in progress
h.Delivery.HoldTexts.SetResult();
h.Delivery.HoldPrompts.SetResult();
await h.Scheduler.IdleAsync();
Assert.Equal(("111", "⏰ Call the dentist (due 26 Sep 13:58, delivered late)"), Assert.Single(h.Delivery.Texts));
Assert.Equal(("111", "[Scheduled job \"Call the dentist\" (k7m2q)]\nThis was due at 26 Sep 13:58; the bot was offline.\nCall the dentist"), Assert.Single(h.Delivery.Prompts));
Assert.Empty(await h.Store.LoadAsync(CancellationToken.None));
Assert.Empty(h.Scheduler.List("111"));
}
@@ -40,10 +40,10 @@ public sealed class JobSchedulerTests
public async Task An_overdue_one_off_within_60_s_has_no_late_note()
{
await using var h = new SchedulerHarness();
await h.Store.SaveAsync([Stored("k7m2q", JobKind.Reminder, new JobSchedule(Start.AddSeconds(-30), null))], CancellationToken.None);
await h.Store.SaveAsync([Stored("k7m2q", JobKind.Prompt, new JobSchedule(Start.AddSeconds(-30), null))], CancellationToken.None);
await h.StartAsync();
await h.Scheduler.IdleAsync();
Assert.Equal(("111", "⏰ Call the dentist"), Assert.Single(h.Delivery.Texts));
Assert.Equal(("111", "[Scheduled job \"Call the dentist\" (k7m2q)]\nCall the dentist"), Assert.Single(h.Delivery.Prompts));
}
[Fact]
@@ -61,13 +61,13 @@ public sealed class JobSchedulerTests
public async Task Cron_does_not_catch_up_after_downtime()
{
await using var h = new SchedulerHarness();
var job = Stored("c1c1c", JobKind.Reminder, new JobSchedule(null, "*/10 * * * *")) with { LastRunAt = Start.AddHours(-3) };
var job = Stored("c1c1c", JobKind.Prompt, new JobSchedule(null, "*/10 * * * *")) with { LastRunAt = Start.AddHours(-3) };
await h.Store.SaveAsync([job], CancellationToken.None);
await h.StartAsync();
await h.Scheduler.IdleAsync();
Assert.Empty(h.Delivery.Texts);
Assert.Empty(h.Delivery.Prompts);
Assert.Equal(new DateTimeOffset(2026, 9, 26, 12, 10, 0, TimeSpan.Zero), Assert.Single(h.Scheduler.List("111")).NextRunAt);
}
@@ -76,31 +76,30 @@ public sealed class JobSchedulerTests
{
await using var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(new NewJob("Later", JobKind.Reminder, At: "2026-09-26T17:03", Text: "Later")); // 15:03Z, 3 h away
await h.CreateAsync(new NewJob("Later", JobKind.Prompt, At: "2026-09-26T17:03", Text: "Later")); // 15:03Z, 3 h away
Assert.Equal(Start.AddHours(1), h.Scheduler.NextWakeAt);
await h.AdvanceAsync(TimeSpan.FromHours(1));
Assert.Empty(h.Delivery.Texts);
Assert.Empty(h.Delivery.Prompts);
Assert.Equal(Start.AddHours(2), h.Scheduler.NextWakeAt);
await h.AdvanceAsync(TimeSpan.FromHours(1));
Assert.Empty(h.Delivery.Texts);
Assert.Empty(h.Delivery.Prompts);
Assert.Equal(Start.AddHours(3), h.Scheduler.NextWakeAt);
await h.AdvanceAsync(TimeSpan.FromHours(1));
Assert.Equal(("111", "⏰ Later"), Assert.Single(h.Delivery.Texts));
Assert.Equal("Later", Assert.Single(h.Delivery.Prompts).Prompt.Split('\n')[^1]);
Assert.Empty(h.Scheduler.List("111"));
}
[Fact]
public async Task A_reminder_is_sent_as_text_and_a_prompt_as_a_prompt()
public async Task A_prompt_runs_through_the_agent_and_sends_no_text()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(new NewJob("Stretch", JobKind.Reminder, At: "2026-09-26T14:04", Text: "Stretch"));
var prompt = await h.CreateAsync(new NewJob("Summary", JobKind.Prompt, At: "2026-09-26T14:04", Text: "Summarise inbox/"));
await h.AdvanceAsync(Minute);
Assert.Equal(("111", "⏰ Stretch"), Assert.Single(h.Delivery.Texts));
Assert.Empty(h.Delivery.Texts);
Assert.Equal(("111", $"[Scheduled job \"Summary\" ({prompt.Job.Id})]\nSummarise inbox/"), Assert.Single(h.Delivery.Prompts));
}
@@ -207,8 +206,8 @@ public sealed class JobSchedulerTests
{
await using var h = new SchedulerHarness();
await h.StartAsync();
var mine = (await h.CreateAsync(new NewJob("Mine", JobKind.Reminder, Cron: "@daily", Text: "a"), "111")).Job;
var theirs = (await h.CreateAsync(new NewJob("Theirs", JobKind.Reminder, Cron: "@daily", Text: "b"), "222")).Job;
var mine = (await h.CreateAsync(new NewJob("Mine", JobKind.Prompt, Cron: "@daily", Text: "a"), "111")).Job;
var theirs = (await h.CreateAsync(new NewJob("Theirs", JobKind.Prompt, Cron: "@daily", Text: "b"), "222")).Job;
Assert.Equal([mine.Id], h.Scheduler.List("111").Select(v => v.Job.Id));
Assert.False(await h.Scheduler.CancelAsync("222", mine.Id, CancellationToken.None));
@@ -243,13 +242,13 @@ public sealed class JobSchedulerTests
{
await using var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(new NewJob("Hourly", JobKind.Reminder, Cron: "0 * * * *", Text: "ping"));
await h.CreateAsync(new NewJob("Once", JobKind.Reminder, At: "2026-09-26T14:30", Text: "once"));
await h.CreateAsync(new NewJob("Hourly", JobKind.Prompt, Cron: "0 * * * *", Text: "ping"));
await h.CreateAsync(new NewJob("Once", JobKind.Prompt, At: "2026-09-26T14:30", Text: "once"));
h.Delivery.Undeliverable.Add("111");
await h.AdvanceAsync(TimeSpan.FromMinutes(57)); // past 14:30 and 15:00 Berlin
Assert.Empty(h.Delivery.Texts);
Assert.Empty(h.Delivery.Prompts);
var jobs = h.Scheduler.List("111");
Assert.Equal(2, jobs.Count);
Assert.Equal(new DateTimeOffset(2026, 9, 26, 14, 0, 0, TimeSpan.Zero), jobs.Single(j => j.Job.Name == "Hourly").NextRunAt); // advanced
@@ -300,8 +299,8 @@ public sealed class JobSchedulerTests
{
await using var h = new SchedulerHarness();
await h.Store.SaveAsync([
Stored("bad11", JobKind.Reminder, new JobSchedule(null, "*/10 * * * *"), target: "not-a-chat"),
Stored("good1", JobKind.Reminder, new JobSchedule(null, "*/10 * * * *")),
Stored("bad11", JobKind.Prompt, new JobSchedule(null, "*/10 * * * *"), target: "not-a-chat"),
Stored("good1", JobKind.Prompt, new JobSchedule(null, "*/10 * * * *")),
], CancellationToken.None);
h.Delivery.BadTargets.Add("not-a-chat");
@@ -311,17 +310,17 @@ public sealed class JobSchedulerTests
Assert.Null(Assert.Single(h.Scheduler.List("not-a-chat")).NextRunAt); // kept, unarmed
Assert.Equal(new DateTimeOffset(2026, 9, 26, 12, 10, 0, TimeSpan.Zero), Assert.Single(h.Scheduler.List("111")).NextRunAt);
await h.AdvanceAsync(TimeSpan.FromMinutes(7));
Assert.Equal(("111", "⏰ Call the dentist"), Assert.Single(h.Delivery.Texts));
Assert.Equal(("111", "[Scheduled job \"Call the dentist\" (good1)]\nCall the dentist"), Assert.Single(h.Delivery.Prompts));
}
[Fact]
public async Task A_one_off_whose_delivery_is_cut_off_by_shutdown_is_kept_and_fires_after_the_next_start()
{
var h = new SchedulerHarness();
await h.Store.SaveAsync([Stored("k7m2q", JobKind.Reminder, new JobSchedule(Start.AddSeconds(-30), null))], CancellationToken.None);
h.Delivery.HoldTexts = new TaskCompletionSource(); // the send is in flight when the bot stops
await h.Store.SaveAsync([Stored("k7m2q", JobKind.Prompt, new JobSchedule(Start.AddSeconds(-30), null))], CancellationToken.None);
h.Delivery.HoldPrompts = new TaskCompletionSource(); // the prompt run is in flight when the bot stops
await h.StartAsync();
await Eventually.UntilAsync(() => h.Delivery.Texts.Count == 1, "the reminder send");
await Eventually.UntilAsync(() => h.Delivery.Prompts.Count == 1, "the prompt run");
await h.Scheduler.StopAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
@@ -332,7 +331,7 @@ public sealed class JobSchedulerTests
await restarted.StartAsync(delivery, CancellationToken.None);
await restarted.IdleAsync();
}
Assert.Equal(("111", "⏰ Call the dentist"), Assert.Single(delivery.Texts));
Assert.Equal(("111", "[Scheduled job \"Call the dentist\" (k7m2q)]\nCall the dentist"), Assert.Single(delivery.Prompts));
Assert.Empty(await h.Store.LoadAsync(CancellationToken.None));
await h.DisposeAsync();
}
@@ -343,9 +342,9 @@ public sealed class JobSchedulerTests
var start = new DateTimeOffset(2026, 9, 26, 23, 30, 0, TimeSpan.Zero); // Sun 01:30 Berlin, Sun 08:30 Tokyo
await using var h = new SchedulerHarness(start);
await h.StartAsync();
var cron = (await h.CreateAsync(new NewJob("Morning", JobKind.Reminder, Cron: "0 9 * * *", Text: "Good morning"))).Job;
var once = (await h.CreateAsync(new NewJob("Dentist", JobKind.Reminder, At: "2026-09-27T10:00", Text: "Dentist"))).Job;
var other = (await h.CreateAsync(new NewJob("Other", JobKind.Reminder, Cron: "0 9 * * *", Text: "x"), "222")).Job;
var cron = (await h.CreateAsync(new NewJob("Morning", JobKind.Prompt, Cron: "0 9 * * *", Text: "Good morning"))).Job;
var once = (await h.CreateAsync(new NewJob("Dentist", JobKind.Prompt, At: "2026-09-27T10:00", Text: "Dentist"))).Job;
var other = (await h.CreateAsync(new NewJob("Other", JobKind.Prompt, Cron: "0 9 * * *", Text: "x"), "222")).Job;
var berlinNine = new DateTimeOffset(2026, 9, 27, 7, 0, 0, TimeSpan.Zero);
Assert.Equal(start.AddHours(1), h.Scheduler.NextWakeAt); // capped
@@ -385,7 +384,7 @@ public sealed class JobSchedulerTests
{
await using var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(new NewJob("Tick", JobKind.Reminder, Cron: "* * * * *", Text: "tick"));
await h.CreateAsync(new NewJob("Tick", JobKind.Prompt, Cron: "* * * * *", Text: "tick"));
h.Delivery.FailSends = new InvalidOperationException("Telegram is down");
await h.AdvanceAsync(Minute);
@@ -400,13 +399,13 @@ public sealed class JobSchedulerTests
{
await using var h = new SchedulerHarness(configure: o => o.MaxJobsPerTarget = 2);
await h.StartAsync();
await h.CreateAsync(new NewJob("a", JobKind.Reminder, Cron: "@daily", Text: "a"));
await h.CreateAsync(new NewJob("b", JobKind.Reminder, Cron: "@daily", Text: "b"));
await h.CreateAsync(new NewJob("a", JobKind.Prompt, Cron: "@daily", Text: "a"));
await h.CreateAsync(new NewJob("b", JobKind.Prompt, Cron: "@daily", Text: "b"));
var full = await Assert.ThrowsAsync<JobValidationException>(() => h.CreateAsync(new NewJob("c", JobKind.Reminder, Cron: "@daily", Text: "c")));
var full = await Assert.ThrowsAsync<JobValidationException>(() => h.CreateAsync(new NewJob("c", JobKind.Prompt, Cron: "@daily", Text: "c")));
Assert.Equal("This chat already has 2 scheduled jobs, the maximum. Cancel one first.", full.Message);
await h.CreateAsync(new NewJob("c", JobKind.Reminder, Cron: "@daily", Text: "c"), "222"); // other targets have their own cap
await Assert.ThrowsAsync<JobValidationException>(() => h.CreateAsync(new NewJob("bad", JobKind.Reminder, Cron: "0 0 9 * * *", Text: "x"), "333"));
await h.CreateAsync(new NewJob("c", JobKind.Prompt, Cron: "@daily", Text: "c"), "222"); // other targets have their own cap
await Assert.ThrowsAsync<JobValidationException>(() => h.CreateAsync(new NewJob("bad", JobKind.Prompt, Cron: "0 0 9 * * *", Text: "x"), "333"));
var ids = h.Scheduler.List("111").Select(v => v.Job.Id).ToList();
Assert.All(ids, id => Assert.Matches("^[abcdefghjkmnpqrstuvwxyz23456789]{5}$", id));

View File

@@ -13,9 +13,9 @@ public sealed class JobStoreTests : IDisposable
private string StorePath => Path.Combine(_root, "scheduler", "jobs.json");
private JobStore Store() => new(StorePath, _time, NullLogger.Instance);
internal static ScheduledJob Reminder(string id, string target = "111") => new()
internal static ScheduledJob Prompt(string id, string target = "111") => new()
{
Id = id, Target = target, Name = "Call the dentist", Kind = JobKind.Reminder, Text = "Call the dentist",
Id = id, Target = target, Name = "Call the dentist", Kind = JobKind.Prompt, Text = "Call the dentist",
Schedule = new JobSchedule(Now.AddDays(1), null), CreatedAt = Now,
};
@@ -36,20 +36,20 @@ public sealed class JobStoreTests : IDisposable
TimeoutSeconds = 60, Schedule = new JobSchedule(null, "*/10 * * * *"), CreatedAt = Now,
LastRunAt = Now, LastResult = new JobResult(false, 1, false, "boom", Now),
};
await Store().SaveAsync([Reminder("k7m2q"), watcher], CancellationToken.None);
await Store().SaveAsync([Prompt("k7m2q"), watcher], CancellationToken.None);
Assert.Equal([Reminder("k7m2q"), watcher], await Store().LoadAsync(CancellationToken.None));
Assert.Equal([Prompt("k7m2q"), watcher], await Store().LoadAsync(CancellationToken.None));
Assert.Equal(["jobs.json"], Directory.GetFiles(Path.GetDirectoryName(StorePath)!).Select(Path.GetFileName));
}
[Fact]
public async Task File_format_is_versioned_camel_case()
{
await Store().SaveAsync([Reminder("k7m2q")], CancellationToken.None);
await Store().SaveAsync([Prompt("k7m2q")], CancellationToken.None);
var json = await File.ReadAllTextAsync(StorePath);
Assert.Contains("\"version\": 1", json);
Assert.Contains("\"jobs\": [", json);
Assert.Contains("\"kind\": \"reminder\"", json);
Assert.Contains("\"kind\": \"prompt\"", json);
Assert.NotEqual(0xEF, (await File.ReadAllBytesAsync(StorePath))[0]); // no BOM
}
@@ -70,10 +70,10 @@ public sealed class JobStoreTests : IDisposable
Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!);
await File.WriteAllTextAsync(StorePath, """
{ "version": 1, "jobs": [
{ "id": "good1", "target": "111", "name": "ok", "kind": "reminder", "text": "hi", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
{ "id": "both1", "target": "111", "name": "both", "kind": "reminder", "text": "hi", "schedule": { "at": "2026-09-27T09:00:00+00:00", "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
{ "id": "good1", "target": "111", "name": "ok", "kind": "prompt", "text": "hi", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
{ "id": "both1", "target": "111", "name": "both", "kind": "prompt", "text": "hi", "schedule": { "at": "2026-09-27T09:00:00+00:00", "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
{ "id": "nocmd", "target": "111", "name": "watch", "kind": "watcher", "schedule": { "cron": "* * * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
{ "id": "good1", "target": "111", "name": "duplicate id", "kind": "reminder", "text": "hi", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" }
{ "id": "good1", "target": "111", "name": "duplicate id", "kind": "prompt", "text": "hi", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" }
] }
""");
@@ -88,9 +88,9 @@ public sealed class JobStoreTests : IDisposable
Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!);
await File.WriteAllTextAsync(StorePath, """
{ "version": 1, "jobs": [
{ "id": "good1", "target": "111", "name": "ok", "kind": "reminder", "text": "hi", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
{ "id": "good1", "target": "111", "name": "ok", "kind": "prompt", "text": "hi", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
{ "id": "kind1", "target": "111", "name": "bad kind", "kind": "alarm", "text": "hi", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
{ "id": "noat1", "target": "111", "name": "no createdAt", "kind": "reminder", "text": "hi", "schedule": { "cron": "0 9 * * *" } },
{ "id": "noat1", "target": "111", "name": "no createdAt", "kind": "prompt", "text": "hi", "schedule": { "cron": "0 9 * * *" } },
null,
42
] }
@@ -102,11 +102,28 @@ public sealed class JobStoreTests : IDisposable
Assert.True(File.Exists(StorePath));
}
[Fact]
public async Task A_stored_reminder_is_loaded_as_a_prompt()
{
Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!);
await File.WriteAllTextAsync(StorePath, """
{ "version": 1, "jobs": [
{ "id": "k7m2q", "target": "111", "name": "Dentist", "kind": "reminder", "text": "Call the dentist", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
{ "id": "empty", "target": "111", "name": "No text", "kind": "reminder", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" }
] }
""");
var job = Assert.Single(await Store().LoadAsync(CancellationToken.None));
Assert.Equal(JobKind.Prompt, job.Kind);
Assert.Equal("Remind me: Call the dentist", job.Text);
}
[Fact]
public async Task Concurrent_saves_are_serialised()
{
var store = Store();
await Task.WhenAll(Enumerable.Range(0, 20).Select(i => Task.Run(() => store.SaveAsync([Reminder($"id{i:000}")], CancellationToken.None))));
await Task.WhenAll(Enumerable.Range(0, 20).Select(i => Task.Run(() => store.SaveAsync([Prompt($"id{i:000}")], CancellationToken.None))));
Assert.Single(await Store().LoadAsync(CancellationToken.None));
Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(StorePath)!, "*.tmp"));

View File

@@ -14,8 +14,8 @@ public sealed class JobValidationTests
public void Past_at_is_rejected_beyond_the_grace()
{
Assert.Equal("at 2026-09-26 13:00 (Europe/Berlin) is in the past; now is 2026-09-26 14:03. Pick a future time.",
Error(new NewJob("x", JobKind.Reminder, At: "2026-09-26T13:00", Text: "hi")));
var schedule = JobValidation.ValidateNew(new NewJob("x", JobKind.Reminder, At: "2026-09-26T12:02:30Z", Text: "hi"), Now, Berlin);
Error(new NewJob("x", JobKind.Prompt, At: "2026-09-26T13:00", Text: "hi")));
var schedule = JobValidation.ValidateNew(new NewJob("x", JobKind.Prompt, At: "2026-09-26T12:02:30Z", Text: "hi"), Now, Berlin);
Assert.Equal(new DateTimeOffset(2026, 9, 26, 12, 2, 30, TimeSpan.Zero), schedule.At); // within 60 s of grace
}
@@ -24,15 +24,14 @@ public sealed class JobValidationTests
[InlineData("2026-09-27T09:00", "0 9 * * *")]
public void Exactly_one_of_at_and_cron(string? at, string? cron) =>
Assert.Equal("Set exactly one of at (a one-off time) or cron (a recurring schedule).",
Error(new NewJob("x", JobKind.Reminder, at, cron, Text: "hi")));
Error(new NewJob("x", JobKind.Prompt, at, cron, Text: "hi")));
[Fact]
public void Kind_specific_fields()
{
const string cron = "0 9 * * *";
Assert.Equal("A reminder needs text (the message to send).", Error(new NewJob("x", JobKind.Reminder, Cron: cron)));
Assert.Equal("A prompt job needs text (what to ask the agent).", Error(new NewJob("x", JobKind.Prompt, Cron: cron)));
Assert.Equal("command is only for watchers.", Error(new NewJob("x", JobKind.Reminder, Cron: cron, Text: "t", Command: "ls")));
Assert.Equal("command is only for watchers.", Error(new NewJob("x", JobKind.Prompt, Cron: cron, Text: "t", Command: "ls")));
Assert.Equal("A watcher needs command (the shell command to run).", Error(new NewJob("x", JobKind.Watcher, Cron: cron)));
Assert.Equal("A watcher takes no text: its script's output becomes the prompt.", Error(new NewJob("x", JobKind.Watcher, Cron: cron, Text: "t", Command: "ls")));
Assert.Equal("timeoutSeconds is only for watchers.", Error(new NewJob("x", JobKind.Prompt, Cron: cron, Text: "t", TimeoutSeconds: 10)));
@@ -44,8 +43,8 @@ public sealed class JobValidationTests
[Fact]
public void Name_is_required_and_capped()
{
Assert.Equal("name is required (a short label such as \"Call the dentist\").", Error(new NewJob(" ", JobKind.Reminder, Cron: "@daily", Text: "t")));
Assert.Equal("name is 81 characters; the limit is 80.", Error(new NewJob(new string('n', 81), JobKind.Reminder, Cron: "@daily", Text: "t")));
Assert.Equal("name is required (a short label such as \"Call the dentist\").", Error(new NewJob(" ", JobKind.Prompt, Cron: "@daily", Text: "t")));
Assert.Equal("name is 81 characters; the limit is 80.", Error(new NewJob(new string('n', 81), JobKind.Prompt, Cron: "@daily", Text: "t")));
}
[Fact]
@@ -57,7 +56,7 @@ public sealed class JobValidationTests
{
var job = new ScheduledJob
{
Id = "k7m2q", Target = "111", Name = "Old", Kind = JobKind.Reminder, Text = "hi",
Id = "k7m2q", Target = "111", Name = "Old", Kind = JobKind.Prompt, Text = "hi",
Schedule = new JobSchedule(Now.AddDays(-3), null), CreatedAt = Now.AddDays(-4),
};
Assert.Null(JobValidation.CheckStored(job)); // an overdue one-off is fine on load: it fires late

View File

@@ -28,14 +28,14 @@ public sealed class ScheduleToolTests
{
await using var h = await StartedAsync();
var created = await Run(h, "111", new { action = "create", name = "Call the dentist", kind = "reminder", at = "2026-09-27T09:00", text = "Call the dentist" });
var created = await Run(h, "111", new { action = "create", name = "Call the dentist", kind = "prompt", at = "2026-09-27T09:00", text = "Call the dentist" });
var id = Assert.Single(h.Scheduler.List("111")).Job.Id;
Assert.Equal(
$"Created: `{id}` ⏰ Call the dentist · once 27 Sep 09:00\nNext run: Sun 2026-09-27 09:00 (Europe/Berlin)\nNow: Sat 2026-09-26 14:03 (Europe/Berlin)",
$"Created: `{id}` 💬 Call the dentist · once 27 Sep 09:00\nNext run: Sun 2026-09-27 09:00 (Europe/Berlin)\nNow: Sat 2026-09-26 14:03 (Europe/Berlin)",
created.TextContent);
var listed = await Run(h, "111", new { action = "list" });
Assert.Equal($"`{id}` ⏰ Call the dentist · once 27 Sep 09:00\nNow: Sat 2026-09-26 14:03 (Europe/Berlin)", listed.TextContent);
Assert.Equal($"`{id}` 💬 Call the dentist · once 27 Sep 09:00\nNow: Sat 2026-09-26 14:03 (Europe/Berlin)", listed.TextContent);
var cancelled = await Run(h, "111", new { action = "cancel", id });
Assert.Equal($"Cancelled {id} (Call the dentist).", cancelled.TextContent);
@@ -43,11 +43,12 @@ public sealed class ScheduleToolTests
}
[Theory]
[InlineData("""{"action":"create","name":"x","kind":"reminder","cron":"0 0 9 * * *","text":"t"}""", "seconds aren't supported")]
[InlineData("""{"action":"create","name":"x","kind":"reminder","at":"2026-09-26T13:00","text":"t"}""", "is in the past")]
[InlineData("""{"action":"create","name":"x","kind":"reminder","cron":"@daily"}""", "A reminder needs text")]
[InlineData("""{"action":"create","name":"x","kind":"alarm","cron":"@daily","text":"t"}""", "kind must be reminder, prompt or watcher.")]
[InlineData("""{"action":"create","name":"x","cron":"@daily","text":"t"}""", "kind must be reminder, prompt or watcher.")]
[InlineData("""{"action":"create","name":"x","kind":"prompt","cron":"0 0 9 * * *","text":"t"}""", "seconds aren't supported")]
[InlineData("""{"action":"create","name":"x","kind":"prompt","at":"2026-09-26T13:00","text":"t"}""", "is in the past")]
[InlineData("""{"action":"create","name":"x","kind":"prompt","cron":"@daily"}""", "A prompt job needs text")]
[InlineData("""{"action":"create","name":"x","kind":"reminder","cron":"@daily","text":"t"}""", "kind must be prompt or watcher.")]
[InlineData("""{"action":"create","name":"x","kind":"alarm","cron":"@daily","text":"t"}""", "kind must be prompt or watcher.")]
[InlineData("""{"action":"create","name":"x","cron":"@daily","text":"t"}""", "kind must be prompt or watcher.")]
[InlineData("""{"action":"snooze"}""", "action must be create, list or cancel.")]
[InlineData("""{"action":"cancel"}""", "cancel needs id (see action=list).")]
[InlineData("""{"action":"cancel","id":"zzzzz"}""", "No job zzzzz in this chat.")]
@@ -67,7 +68,7 @@ public sealed class ScheduleToolTests
public async Task List_and_cancel_never_see_another_targets_jobs()
{
await using var h = await StartedAsync();
await Run(h, "222", new { action = "create", name = "Theirs", kind = "reminder", cron = "@daily", text = "t" });
await Run(h, "222", new { action = "create", name = "Theirs", kind = "prompt", cron = "@daily", text = "t" });
var theirs = Assert.Single(h.Scheduler.List("222")).Job.Id;
Assert.StartsWith("No scheduled jobs.", (await Run(h, "111", new { action = "list" })).TextContent);
@@ -97,7 +98,7 @@ public sealed class ScheduleToolTests
await using var h = await StartedAsync();
var tool = new ScheduleTool(h.Scheduler, "111");
Assert.Equal("schedule", tool.Name);
Assert.Equal("schedule: reminders, recurring prompts and watcher scripts that message this chat", tool.PromptSnippet);
Assert.Equal("schedule: one-off and recurring prompts and watcher scripts that message this chat", tool.PromptSnippet);
Assert.Equal(4, tool.PromptGuidelines.Count);
Assert.DoesNotContain(tool.PromptGuidelines, g => g.Contains("Europe/", StringComparison.Ordinal)); // zone-free, so the prompt text is stable
Assert.Contains(tool.PromptGuidelines, g => g.Contains("scripts/", StringComparison.Ordinal));

View File

@@ -44,6 +44,7 @@ internal sealed class FakeJobDelivery : IJobDelivery
{
lock (_lock) { _calls.Add("RunPrompt:" + target); _prompts.Add((target, prompt)); }
if (HoldPrompts is { } hold) await hold.Task.WaitAsync(cancellationToken);
if (FailSends is { } error) throw error;
}
public string WorkspaceFor(string target)

View File

@@ -100,7 +100,7 @@ public sealed class ScheduledPromptChatTests
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.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();
@@ -110,7 +110,7 @@ public sealed class ScheduledPromptChatTests
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());
Assert.Contains("💬 Stretch · 0 * * * * · next 15:00", host.LastReply());
}
[Fact]
@@ -123,17 +123,19 @@ public sealed class ScheduledPromptChatTests
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);
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 (await second.ChatAsync()).Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
await second.SettleAsync();
Assert.Equal("⏰ Call the dentist (due 26 Sep 15:03, delivered late)", Assert.Single(second.Bot.Sent).Text);
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
}
@@ -185,8 +187,8 @@ public sealed class ScheduledPromptChatTests
{
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.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");

View File

@@ -38,28 +38,6 @@ public sealed class SchedulerChatTests
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()
{
@@ -104,13 +82,13 @@ public sealed class SchedulerChatTests
{
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 once = await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Call the dentist", JobKind.Prompt, 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",
$"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());
}
@@ -128,8 +106,8 @@ public sealed class SchedulerChatTests
{
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;
var mine = (await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Call the dentist", JobKind.Prompt, Cron: "@daily", Text: "x"), CancellationToken.None)).Job.Id;
var theirs = (await host.Scheduler.CreateAsync("2002", new NewJob("Theirs", JobKind.Prompt, Cron: "@daily", Text: "y"), CancellationToken.None)).Job.Id;
await host.SendAsync($"/cancel {theirs}");
Assert.Equal($"No job {theirs} in this chat.", host.LastReply());
@@ -156,7 +134,7 @@ public sealed class SchedulerChatTests
{
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.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("a", JobKind.Prompt, Cron: "@daily", Text: "x"), CancellationToken.None);
await host.SendAsync("/status");
Assert.Contains("Jobs: 1\n", host.LastReply());
}