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:
@@ -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): ...`,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user