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. 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`. 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 ## Conventions
- Commits follow Conventional Commits scoped by area: `feat(claude-code): ...`, `fix(telegram): ...`, `docs(spec): ...`, - 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 | | 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 | | 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 | | 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 | | 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 | | Reference headless host | `samples/Volcanic.AgenticCore.Sample.Headless` | Prompt in, JSON events out |
| Tests | `tests/` | xunit; no test calls a paid API | | 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 `GoogleCalendar` is a feature extension: a tool and three commands, per-chat accounts through process-wide
`GoogleCalendarConnections` (see [google-calendar.md](google-calendar.md)). `GoogleCalendarConnections` (see [google-calendar.md](google-calendar.md)).
`Scheduler` is a feature extension with a process-wide `JobScheduler` (reminders, scheduled prompts and watcher `Scheduler` is a feature extension with a process-wide `JobScheduler` (scheduled prompts and watcher scripts
scripts on a `TimeProvider` timer, jobs in `<AgentDir>/scheduler/jobs.json`), a per-session `schedule` tool bound to a 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)). host target, and an `IJobDelivery` the host implements (see [telegram-bot.md](telegram-bot.md#scheduling)).
## Vertical slices ## Vertical slices

View File

@@ -1,12 +1,21 @@
# Scheduler: reminders, scheduled prompts and watcher scripts (design) # Scheduler: reminders, scheduled prompts and watcher scripts (design)
Date: 2026-09-26 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 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 `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` 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). (`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 ## 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 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: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 | | `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`) | | `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: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:DefaultScriptTimeout` | `00:05:00` | Watcher timeout when a job sets none (1 s to 1 h) |
| `Scheduler:MaxJobsPerTarget` | `50` | Jobs per chat | | `Scheduler:MaxJobsPerTarget` | `50` | Jobs per chat |
@@ -147,12 +147,11 @@ chat's workspace and delivers only to that chat.
### Scheduling ### 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, - "Remind me at 9 tomorrow to call the dentist" or "Every weekday at 8, summarise what's in `inbox/`" is a
`⏰ Call the dentist`, with no model call. **prompt**: when it fires, the agent handles it like a message from you, with the same time header, and writes the
- "Every weekday at 8, summarise what's in `inbox/`" is a **prompt**: when it fires, the agent handles it like a reminder or the summary.
message from you, with the same time header.
- "Check this page every 10 minutes and tell me only if the price drops below 50" is a **watcher**. - "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 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 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. 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 After a restart, a one-off that came due while the bot was down is delivered as soon as it's back, with
`(due …, delivered late)` (for prompts: "This was due at …; the bot was offline."). Recurring jobs don't catch up: "This was due at …; the bot was offline." in the prompt. Recurring jobs don't catch up:
missed occurrences are skipped. 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 Jobs are per chat: `/jobs` and the agent only see this chat's jobs. Watcher failure alerts aren't part of the
of the conversation, so the agent can't see them later; use a prompt job for things that should be. With 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. `Scheduler:Enabled=false`, `/jobs` and `/cancel` reply that scheduling is turned off, and `/timezone` still works.
### Google Calendar ### 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` - `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. 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 - `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 - `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 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 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> /// <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 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> /// <summary><c>27 Sep 09:00</c> in <paramref name="zone"/>.</summary>
public static string DayTime(DateTimeOffset at, TimeZoneInfo zone) => public static string DayTime(DateTimeOffset at, TimeZoneInfo zone) =>
@@ -36,7 +36,7 @@ public static class JobFormat
_ => "did not start", _ => "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) public static string Line(JobView view, TimeZoneInfo zone, DateTimeOffset now)
{ {
var job = view.Job; 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> /// <summary>The texts a fire sends (spec §4 table).</summary>
internal static class JobMessages 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) => 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; $"{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; JobResult? result;
switch (job.Kind) 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: case JobKind.Prompt:
result = await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.Prompt(job, lateDue, zone), ct)).ConfigureAwait(false) 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()) ? new JobResult(true, null, false, null, _time.GetUtcNow())

View File

@@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Json; using Volcanic.AgenticCore.Abstractions.Json;
@@ -8,11 +9,13 @@ namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary> /// <summary>
/// <c>jobs.json</c>: <c>{ "version": 1, "jobs": [...] }</c> in <see cref="VolcanicJson"/> camelCase. Written atomically /// <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 /// (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> /// </summary>
public sealed class JobStore(string path, TimeProvider time, ILogger logger) public sealed class JobStore(string path, TimeProvider time, ILogger logger)
{ {
public const int Version = 1; public const int Version = 1;
private const string ReminderPrefix = "Remind me: ";
private readonly SemaphoreSlim _writeGate = new(1, 1); private readonly SemaphoreSlim _writeGate = new(1, 1);
public string Path => path; public string Path => path;
@@ -83,7 +86,7 @@ public sealed class JobStore(string path, TimeProvider time, ILogger logger)
ScheduledJob? job; ScheduledJob? job;
try try
{ {
job = VolcanicJson.Deserialize<ScheduledJob>(entry); job = VolcanicJson.Deserialize<ScheduledJob>(FromReminder(entry));
} }
catch (JsonException ex) catch (JsonException ex)
{ {
@@ -93,6 +96,20 @@ public sealed class JobStore(string path, TimeProvider time, ILogger logger)
return (job, JobValidation.CheckStored(job)); 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 JobFile(int Version, List<ScheduledJob> Jobs);
private sealed record StoredFile(int Version, List<JsonElement>? 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); var hasCommand = !string.IsNullOrWhiteSpace(command);
switch (kind) 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.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 !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 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 (hasText && text!.Length > MaxTextLength) throw new JobValidationException($"text is {Count(text.Length)} characters; the limit is {Count(MaxTextLength)}.");
if (timeoutSeconds is { } seconds) if (timeoutSeconds is { } seconds)

View File

@@ -1,7 +1,7 @@
namespace Volcanic.AgenticCore.Extensions.Scheduler; 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> /// <summary>Prompt: fixed text through the agent. Watcher: a script whose output goes to the agent.</summary>
public enum JobKind { Reminder, Prompt, Watcher } 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> /// <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); public sealed record JobSchedule(DateTimeOffset? At, string? Cron);
@@ -18,7 +18,7 @@ public sealed record ScheduledJob
public required string Name { get; init; } public required string Name { get; init; }
public required JobKind Kind { get; init; } public required JobKind Kind { get; init; }
public required JobSchedule Schedule { get; init; } public required JobSchedule Schedule { get; init; }
/// <summary>Reminder and prompt only.</summary> /// <summary>Prompt only.</summary>
public string? Text { get; init; } public string? Text { get; init; }
/// <summary>Watcher only.</summary> /// <summary>Watcher only.</summary>
public string? Command { get; init; } 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("create, list or cancel")] string Action,
[property: Description("cancel: the job id (see list)")] string? Id = null, [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: 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, 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, 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: shell command run from the workspace")] string? Command = null,
[property: Description("create, watcher: timeout in seconds, 1-3600 (default 300)")] int? TimeoutSeconds = 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 Name => ToolName;
public override string Description => 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 => 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: 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: 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.", "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 var kind = (args.Kind ?? "").Trim().ToLowerInvariant() switch
{ {
"reminder" => JobKind.Reminder,
"prompt" => JobKind.Prompt, "prompt" => JobKind.Prompt,
"watcher" => JobKind.Watcher, "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 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); 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 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 = 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>" }, 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() public void Job_lines_show_kind_schedule_next_and_last_failure()
{ {
var once = new DateTimeOffset(2026, 9, 27, 7, 0, 0, TimeSpan.Zero); var once = new DateTimeOffset(2026, 9, 27, 7, 0, 0, TimeSpan.Zero);
Assert.Equal("`k7m2q` ⏰ Call the dentist · once 27 Sep 09:00", 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)); 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", 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)); 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) }; 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() 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 using var h = new SchedulerHarness();
await h.Store.SaveAsync([Stored("k7m2q", JobKind.Reminder, new JobSchedule(Start.AddMinutes(-5), null))], CancellationToken.None); await h.Store.SaveAsync([Stored("k7m2q", JobKind.Prompt, new JobSchedule(Start.AddMinutes(-5), null))], CancellationToken.None);
h.Delivery.HoldTexts = new TaskCompletionSource(); h.Delivery.HoldPrompts = new TaskCompletionSource();
await h.StartAsync(); 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 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(); 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(await h.Store.LoadAsync(CancellationToken.None));
Assert.Empty(h.Scheduler.List("111")); 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() public async Task An_overdue_one_off_within_60_s_has_no_late_note()
{ {
await using var h = new SchedulerHarness(); 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.StartAsync();
await h.Scheduler.IdleAsync(); 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] [Fact]
@@ -61,13 +61,13 @@ public sealed class JobSchedulerTests
public async Task Cron_does_not_catch_up_after_downtime() public async Task Cron_does_not_catch_up_after_downtime()
{ {
await using var h = new SchedulerHarness(); 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.Store.SaveAsync([job], CancellationToken.None);
await h.StartAsync(); await h.StartAsync();
await h.Scheduler.IdleAsync(); 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); 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 using var h = new SchedulerHarness();
await h.StartAsync(); 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); Assert.Equal(Start.AddHours(1), h.Scheduler.NextWakeAt);
await h.AdvanceAsync(TimeSpan.FromHours(1)); await h.AdvanceAsync(TimeSpan.FromHours(1));
Assert.Empty(h.Delivery.Texts); Assert.Empty(h.Delivery.Prompts);
Assert.Equal(Start.AddHours(2), h.Scheduler.NextWakeAt); Assert.Equal(Start.AddHours(2), h.Scheduler.NextWakeAt);
await h.AdvanceAsync(TimeSpan.FromHours(1)); await h.AdvanceAsync(TimeSpan.FromHours(1));
Assert.Empty(h.Delivery.Texts); Assert.Empty(h.Delivery.Prompts);
Assert.Equal(Start.AddHours(3), h.Scheduler.NextWakeAt); Assert.Equal(Start.AddHours(3), h.Scheduler.NextWakeAt);
await h.AdvanceAsync(TimeSpan.FromHours(1)); 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")); Assert.Empty(h.Scheduler.List("111"));
} }
[Fact] [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 using var h = new SchedulerHarness();
await h.StartAsync(); 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/")); var prompt = await h.CreateAsync(new NewJob("Summary", JobKind.Prompt, At: "2026-09-26T14:04", Text: "Summarise inbox/"));
await h.AdvanceAsync(Minute); 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)); 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 using var h = new SchedulerHarness();
await h.StartAsync(); await h.StartAsync();
var mine = (await h.CreateAsync(new NewJob("Mine", JobKind.Reminder, Cron: "@daily", Text: "a"), "111")).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.Reminder, Cron: "@daily", Text: "b"), "222")).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.Equal([mine.Id], h.Scheduler.List("111").Select(v => v.Job.Id));
Assert.False(await h.Scheduler.CancelAsync("222", mine.Id, CancellationToken.None)); 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 using var h = new SchedulerHarness();
await h.StartAsync(); await h.StartAsync();
await h.CreateAsync(new NewJob("Hourly", JobKind.Reminder, Cron: "0 * * * *", Text: "ping")); await h.CreateAsync(new NewJob("Hourly", JobKind.Prompt, Cron: "0 * * * *", Text: "ping"));
await h.CreateAsync(new NewJob("Once", JobKind.Reminder, At: "2026-09-26T14:30", Text: "once")); await h.CreateAsync(new NewJob("Once", JobKind.Prompt, At: "2026-09-26T14:30", Text: "once"));
h.Delivery.Undeliverable.Add("111"); h.Delivery.Undeliverable.Add("111");
await h.AdvanceAsync(TimeSpan.FromMinutes(57)); // past 14:30 and 15:00 Berlin 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"); var jobs = h.Scheduler.List("111");
Assert.Equal(2, jobs.Count); 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 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 using var h = new SchedulerHarness();
await h.Store.SaveAsync([ await h.Store.SaveAsync([
Stored("bad11", JobKind.Reminder, new JobSchedule(null, "*/10 * * * *"), target: "not-a-chat"), Stored("bad11", JobKind.Prompt, new JobSchedule(null, "*/10 * * * *"), target: "not-a-chat"),
Stored("good1", JobKind.Reminder, new JobSchedule(null, "*/10 * * * *")), Stored("good1", JobKind.Prompt, new JobSchedule(null, "*/10 * * * *")),
], CancellationToken.None); ], CancellationToken.None);
h.Delivery.BadTargets.Add("not-a-chat"); 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.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); 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)); 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] [Fact]
public async Task A_one_off_whose_delivery_is_cut_off_by_shutdown_is_kept_and_fires_after_the_next_start() 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(); 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);
h.Delivery.HoldTexts = new TaskCompletionSource(); // the send is in flight when the bot stops h.Delivery.HoldPrompts = new TaskCompletionSource(); // the prompt run is in flight when the bot stops
await h.StartAsync(); 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)); 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.StartAsync(delivery, CancellationToken.None);
await restarted.IdleAsync(); 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)); Assert.Empty(await h.Store.LoadAsync(CancellationToken.None));
await h.DisposeAsync(); 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 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 using var h = new SchedulerHarness(start);
await h.StartAsync(); await h.StartAsync();
var cron = (await h.CreateAsync(new NewJob("Morning", JobKind.Reminder, Cron: "0 9 * * *", Text: "Good morning"))).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.Reminder, At: "2026-09-27T10:00", Text: "Dentist"))).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.Reminder, Cron: "0 9 * * *", Text: "x"), "222")).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); var berlinNine = new DateTimeOffset(2026, 9, 27, 7, 0, 0, TimeSpan.Zero);
Assert.Equal(start.AddHours(1), h.Scheduler.NextWakeAt); // capped Assert.Equal(start.AddHours(1), h.Scheduler.NextWakeAt); // capped
@@ -385,7 +384,7 @@ public sealed class JobSchedulerTests
{ {
await using var h = new SchedulerHarness(); await using var h = new SchedulerHarness();
await h.StartAsync(); 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"); h.Delivery.FailSends = new InvalidOperationException("Telegram is down");
await h.AdvanceAsync(Minute); await h.AdvanceAsync(Minute);
@@ -400,13 +399,13 @@ public sealed class JobSchedulerTests
{ {
await using var h = new SchedulerHarness(configure: o => o.MaxJobsPerTarget = 2); await using var h = new SchedulerHarness(configure: o => o.MaxJobsPerTarget = 2);
await h.StartAsync(); await h.StartAsync();
await h.CreateAsync(new NewJob("a", JobKind.Reminder, Cron: "@daily", Text: "a")); await h.CreateAsync(new NewJob("a", JobKind.Prompt, Cron: "@daily", Text: "a"));
await h.CreateAsync(new NewJob("b", JobKind.Reminder, Cron: "@daily", Text: "b")); 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); 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 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.Reminder, Cron: "0 0 9 * * *", Text: "x"), "333")); 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(); var ids = h.Scheduler.List("111").Select(v => v.Job.Id).ToList();
Assert.All(ids, id => Assert.Matches("^[abcdefghjkmnpqrstuvwxyz23456789]{5}$", id)); 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 string StorePath => Path.Combine(_root, "scheduler", "jobs.json");
private JobStore Store() => new(StorePath, _time, NullLogger.Instance); 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, 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, TimeoutSeconds = 60, Schedule = new JobSchedule(null, "*/10 * * * *"), CreatedAt = Now,
LastRunAt = Now, LastResult = new JobResult(false, 1, false, "boom", 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)); Assert.Equal(["jobs.json"], Directory.GetFiles(Path.GetDirectoryName(StorePath)!).Select(Path.GetFileName));
} }
[Fact] [Fact]
public async Task File_format_is_versioned_camel_case() 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); var json = await File.ReadAllTextAsync(StorePath);
Assert.Contains("\"version\": 1", json); Assert.Contains("\"version\": 1", json);
Assert.Contains("\"jobs\": [", 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 Assert.NotEqual(0xEF, (await File.ReadAllBytesAsync(StorePath))[0]); // no BOM
} }
@@ -70,10 +70,10 @@ public sealed class JobStoreTests : IDisposable
Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!); Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!);
await File.WriteAllTextAsync(StorePath, """ await File.WriteAllTextAsync(StorePath, """
{ "version": 1, "jobs": [ { "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": "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": "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": "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)!); Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!);
await File.WriteAllTextAsync(StorePath, """ await File.WriteAllTextAsync(StorePath, """
{ "version": 1, "jobs": [ { "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": "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, null,
42 42
] } ] }
@@ -102,11 +102,28 @@ public sealed class JobStoreTests : IDisposable
Assert.True(File.Exists(StorePath)); 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] [Fact]
public async Task Concurrent_saves_are_serialised() public async Task Concurrent_saves_are_serialised()
{ {
var store = Store(); 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.Single(await Store().LoadAsync(CancellationToken.None));
Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(StorePath)!, "*.tmp")); 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() 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.", 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"))); Error(new NewJob("x", JobKind.Prompt, 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); 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 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 * * *")] [InlineData("2026-09-27T09:00", "0 9 * * *")]
public void Exactly_one_of_at_and_cron(string? at, string? cron) => 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).", 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] [Fact]
public void Kind_specific_fields() public void Kind_specific_fields()
{ {
const string cron = "0 9 * * *"; 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("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 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("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))); 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] [Fact]
public void Name_is_required_and_capped() 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 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.Reminder, 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] [Fact]
@@ -57,7 +56,7 @@ public sealed class JobValidationTests
{ {
var job = new ScheduledJob 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), 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 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(); 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; var id = Assert.Single(h.Scheduler.List("111")).Job.Id;
Assert.Equal( 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); created.TextContent);
var listed = await Run(h, "111", new { action = "list" }); 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 }); var cancelled = await Run(h, "111", new { action = "cancel", id });
Assert.Equal($"Cancelled {id} (Call the dentist).", cancelled.TextContent); Assert.Equal($"Cancelled {id} (Call the dentist).", cancelled.TextContent);
@@ -43,11 +43,12 @@ public sealed class ScheduleToolTests
} }
[Theory] [Theory]
[InlineData("""{"action":"create","name":"x","kind":"reminder","cron":"0 0 9 * * *","text":"t"}""", "seconds aren't supported")] [InlineData("""{"action":"create","name":"x","kind":"prompt","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":"prompt","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":"prompt","cron":"@daily"}""", "A prompt job 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","kind":"reminder","cron":"@daily","text":"t"}""", "kind must be 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":"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":"snooze"}""", "action must be create, list or cancel.")]
[InlineData("""{"action":"cancel"}""", "cancel needs id (see action=list).")] [InlineData("""{"action":"cancel"}""", "cancel needs id (see action=list).")]
[InlineData("""{"action":"cancel","id":"zzzzz"}""", "No job zzzzz in this chat.")] [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() public async Task List_and_cancel_never_see_another_targets_jobs()
{ {
await using var h = await StartedAsync(); 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; var theirs = Assert.Single(h.Scheduler.List("222")).Job.Id;
Assert.StartsWith("No scheduled jobs.", (await Run(h, "111", new { action = "list" })).TextContent); 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(); await using var h = await StartedAsync();
var tool = new ScheduleTool(h.Scheduler, "111"); var tool = new ScheduleTool(h.Scheduler, "111");
Assert.Equal("schedule", tool.Name); 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.Equal(4, tool.PromptGuidelines.Count);
Assert.DoesNotContain(tool.PromptGuidelines, g => g.Contains("Europe/", StringComparison.Ordinal)); // zone-free, so the prompt text is stable 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)); 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)); } lock (_lock) { _calls.Add("RunPrompt:" + target); _prompts.Add((target, prompt)); }
if (HoldPrompts is { } hold) await hold.Task.WaitAsync(cancellationToken); if (HoldPrompts is { } hold) await hold.Task.WaitAsync(cancellationToken);
if (FailSends is { } error) throw error;
} }
public string WorkspaceFor(string target) public string WorkspaceFor(string target)

View File

@@ -100,7 +100,7 @@ public sealed class ScheduledPromptChatTests
var (host, time) = await SchedulerChatTests.StartedAsync(); var (host, time) = await SchedulerChatTests.StartedAsync();
await using var _ = host; await using var _ = host;
host.Faux.Enqueue( 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.")); FauxTurn.Say("Scheduled."));
var chat = await host.ChatAsync(); 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("Stretch", Assert.Single(host.Scheduler!.List(ChatHost.Target)).Job.Name);
Assert.Equal("No scheduled jobs.", host.LastReply(OtherUser)); Assert.Equal("No scheduled jobs.", host.LastReply(OtherUser));
await host.SendAsync("/jobs"); await host.SendAsync("/jobs");
Assert.Contains("⏰ Stretch · 0 * * * * · next 15:00", host.LastReply()); Assert.Contains("💬 Stretch · 0 * * * * · next 15:00", host.LastReply());
} }
[Fact] [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 using (var first = new ChatHost(core: o => o.DefaultTimeZone = Berlin, time: before, root: root, scheduler: new SchedulerOptions()))
{ {
await first.StartSchedulerAsync(); 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("Dentist", JobKind.Prompt, 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("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 var after = new FakeTimeProvider(Now.AddHours(3)); // the bot was down from 14:03 to 17:03
await using var second = new ChatHost(core: o => o.DefaultTimeZone = Berlin, time: after, root: root, scheduler: new SchedulerOptions()); await 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.StartSchedulerAsync();
await second.Scheduler!.IdleAsync(); 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)); 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 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(); var (host, _) = await SchedulerChatTests.StartedAsync();
await using var __ = host; 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("Morning", JobKind.Prompt, 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("Dentist", JobKind.Prompt, At: "2026-09-27T09:00", Text: "Dentist"), CancellationToken.None);
await host.SendAsync("/timezone Asia/Tokyo"); await host.SendAsync("/timezone Asia/Tokyo");

View File

@@ -38,28 +38,6 @@ public sealed class SchedulerChatTests
await host.Scheduler!.IdleAsync(); 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] [Fact]
public async Task A_silent_watcher_with_a_fake_runner_creates_no_session_and_sends_nothing() 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(); var (host, _) = await StartedAsync();
await using var __ = host; 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); 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"); await host.SendAsync("/jobs");
Assert.Equal( 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()); host.LastReply());
} }
@@ -128,8 +106,8 @@ public sealed class SchedulerChatTests
{ {
var (host, _) = await StartedAsync(); var (host, _) = await StartedAsync();
await using var __ = host; 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 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.Reminder, Cron: "@daily", Text: "y"), 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}"); await host.SendAsync($"/cancel {theirs}");
Assert.Equal($"No job {theirs} in this chat.", host.LastReply()); Assert.Equal($"No job {theirs} in this chat.", host.LastReply());
@@ -156,7 +134,7 @@ public sealed class SchedulerChatTests
{ {
var (host, _) = await StartedAsync(); var (host, _) = await StartedAsync();
await using var __ = host; 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"); await host.SendAsync("/status");
Assert.Contains("Jobs: 1\n", host.LastReply()); Assert.Contains("Jobs: 1\n", host.LastReply());
} }