From 8014a0e7708bdd847135f50f3e711d7803bcec42 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Tue, 29 Sep 2026 09:30:40 +0000 Subject: [PATCH] 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: "). Also records the Coolify deploy target in CLAUDE.md. --- CLAUDE.md | 6 ++ README.md | 2 +- docs/architecture.md | 4 +- .../specs/2026-09-26-scheduler-design.md | 11 ++- docs/telegram-bot.md | 19 +++-- docs/ui-integration.md | 2 +- .../JobFormat.cs | 4 +- .../JobMessages.cs | 3 - .../JobScheduler.cs | 5 -- .../JobStore.cs | 21 +++++- .../JobValidation.cs | 5 +- .../Jobs.cs | 6 +- .../ScheduleTool.cs | 13 ++-- .../Scheduling/SchedulerSetup.cs | 4 +- .../Scheduler/JobFormatTests.cs | 4 +- .../Scheduler/JobSchedulerTests.cs | 73 +++++++++---------- .../Scheduler/JobStoreTests.cs | 41 ++++++++--- .../Scheduler/JobValidationTests.cs | 15 ++-- .../Scheduler/ScheduleToolTests.cs | 21 +++--- .../Scheduler/Support/SchedulerFakes.cs | 1 + .../ScheduledPromptChatTests.cs | 18 +++-- .../SchedulerChatTests.cs | 32 ++------ 22 files changed, 164 insertions(+), 146 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c334eea..7791a8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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): ...`, diff --git a/README.md b/README.md index 34cf6f5..3b0e13b 100644 --- a/README.md +++ b/README.md @@ -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 `/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 `/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 | diff --git a/docs/architecture.md b/docs/architecture.md index b9308a1..8ca0bd4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 `/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 `/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 diff --git a/docs/superpowers/specs/2026-09-26-scheduler-design.md b/docs/superpowers/specs/2026-09-26-scheduler-design.md index eb24d98..631a0f1 100644 --- a/docs/superpowers/specs/2026-09-26-scheduler-design.md +++ b/docs/superpowers/specs/2026-09-26-scheduler-design.md @@ -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: ` +(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 diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index 16c7446..5687fbc 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -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` | `/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 diff --git a/docs/ui-integration.md b/docs/ui-integration.md index 1a34e94..05f6903 100644 --- a/docs/ui-integration.md +++ b/docs/ui-integration.md @@ -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 diff --git a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobFormat.cs b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobFormat.cs index bfc38a3..f4d95da 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobFormat.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobFormat.cs @@ -6,7 +6,7 @@ namespace Volcanic.AgenticCore.Extensions.Scheduler; /// How jobs and times are shown (the schedule tool, /jobs, zone-change replies). Invariant culture, 24 h. 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 ? "💬" : "🔍"; /// 27 Sep 09:00 in . public static string DayTime(DateTimeOffset at, TimeZoneInfo zone) => @@ -36,7 +36,7 @@ public static class JobFormat _ => "did not start", }; - /// `k7m2q` ⏰ Call the dentist · once 27 Sep 09:00; cron jobs show the expression and the next run; a failed last run is appended. + /// `k7m2q` 💬 Call the dentist · once 27 Sep 09:00; cron jobs show the expression and the next run; a failed last run is appended. public static string Line(JobView view, TimeZoneInfo zone, DateTimeOffset now) { var job = view.Job; diff --git a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobMessages.cs b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobMessages.cs index 926ddc1..9388fa8 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobMessages.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobMessages.cs @@ -3,9 +3,6 @@ namespace Volcanic.AgenticCore.Extensions.Scheduler; /// The texts a fire sends (spec §4 table). 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; diff --git a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobScheduler.cs b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobScheduler.cs index 8eeb7a1..daf1ce3 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobScheduler.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobScheduler.cs @@ -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()) diff --git a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobStore.cs b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobStore.cs index 8733582..57af1f5 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobStore.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobStore.cs @@ -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; /// /// jobs.json: { "version": 1, "jobs": [...] } in 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 reminder kind is loaded as a prompt asking the agent to pass the reminder on. /// 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(entry); + job = VolcanicJson.Deserialize(FromReminder(entry)); } catch (JsonException ex) { @@ -93,6 +96,20 @@ public sealed class JobStore(string path, TimeProvider time, ILogger logger) return (job, JobValidation.CheckStored(job)); } + /// A stored reminder (fixed text sent as is) becomes a prompt; other entries pass through unchanged. + 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 Jobs); private sealed record StoredFile(int Version, List? Jobs); diff --git a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobValidation.cs b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobValidation.cs index 1697073..6265a3b 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobValidation.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobValidation.cs @@ -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) diff --git a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/Jobs.cs b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/Jobs.cs index ea48f95..29823e5 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/Jobs.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/Jobs.cs @@ -1,7 +1,7 @@ namespace Volcanic.AgenticCore.Extensions.Scheduler; -/// Reminder: fixed text to the chat, no model call. Prompt: fixed text through the agent. Watcher: a script whose output goes to the agent. -public enum JobKind { Reminder, Prompt, Watcher } +/// Prompt: fixed text through the agent. Watcher: a script whose output goes to the agent. +public enum JobKind { Prompt, Watcher } /// Exactly one is set. is an absolute instant; is 5-field wall-clock time in the target's zone. 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; } - /// Reminder and prompt only. + /// Prompt only. public string? Text { get; init; } /// Watcher only. public string? Command { get; init; } diff --git a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/ScheduleTool.cs b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/ScheduleTool.cs index a5f2355..776b92c 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/ScheduleTool.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/ScheduleTool.cs @@ -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 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 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 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); diff --git a/hosts/Volcanic.AgenticCore.Telegram/Scheduling/SchedulerSetup.cs b/hosts/Volcanic.AgenticCore.Telegram/Scheduling/SchedulerSetup.cs index e4cb2da..2796dcb 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Scheduling/SchedulerSetup.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Scheduling/SchedulerSetup.cs @@ -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 – cancel one of them"; + public const string HelpLines = "\n/jobs – this chat's scheduled prompts and watchers\n/cancel – cancel one of them"; public static readonly IReadOnlyList 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 " }, ]; diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobFormatTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobFormatTests.cs index 8d32ec4..fb32790 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobFormatTests.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobFormatTests.cs @@ -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) }; diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobSchedulerTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobSchedulerTests.cs index aebaaaf..992254c 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobSchedulerTests.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobSchedulerTests.cs @@ -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(() => h.CreateAsync(new NewJob("c", JobKind.Reminder, Cron: "@daily", Text: "c"))); + var full = await Assert.ThrowsAsync(() => 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(() => 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(() => 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)); diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobStoreTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobStoreTests.cs index a1447b9..a25c45a 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobStoreTests.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobStoreTests.cs @@ -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")); diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobValidationTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobValidationTests.cs index d4153ab..b80df9e 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobValidationTests.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobValidationTests.cs @@ -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 diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/ScheduleToolTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/ScheduleToolTests.cs index c3b7c8f..c359296 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/ScheduleToolTests.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/ScheduleToolTests.cs @@ -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)); diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/Support/SchedulerFakes.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/Support/SchedulerFakes.cs index b121fb9..da3615c 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/Support/SchedulerFakes.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/Support/SchedulerFakes.cs @@ -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) diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/ScheduledPromptChatTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/ScheduledPromptChatTests.cs index 43c1330..c2b7adb 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/ScheduledPromptChatTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/ScheduledPromptChatTests.cs @@ -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"); diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/SchedulerChatTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/SchedulerChatTests.cs index 8a0a880..dc62882 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/SchedulerChatTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/SchedulerChatTests.cs @@ -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 dentist", 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()); }