Merge branch 'feat/scheduler'

This commit is contained in:
EugeneTes
2026-09-27 11:00:31 +02:00
70 changed files with 8974 additions and 80 deletions

View File

@@ -56,6 +56,8 @@ Read `docs/architecture.md` first. Key points:
- **One conversation = one `AgentSession`**, built by `IAgentSessionFactory.CreateAsync`. Extensions are
**instantiated per session** and each session has its own `ModelRegistry`, so provider registrations and extension
fields are per conversation. Process-wide state has to be explicit (e.g. `ClaudeLoginCoordinator.Shared`).
`AgentSession.SetTimeZone` changes the session's zone (system prompt from the next run); see "Time zone" in
`docs/architecture.md`.
- **Two event systems.** `AgentSessionEvent`s go to hosts via `session.Subscribe` and are invoked synchronously on the
loop. `ExtensionEvent`s go to extensions via `api.On<T>`: they are mutable event args (`Block`, `Cancel`,
`MarkHandled`, set `Text`/`Message`/...), run sequentially in load order, and some short-circuit.
@@ -80,9 +82,11 @@ Read `docs/architecture.md` first. Key points:
Extensions built on the public API only: providers `Anthropic` (Messages API), `OpenAI` (chat-completions compatible),
`ClaudeCode` + `ClaudeCode.Bridge`; `Mcp` (an MCP client: stdio servers as tools, plus the Playwright browser
preset; `docs/mcp.md`); and the `GoogleCalendar` feature extension (`calendar` tool,
preset; `docs/mcp.md`); the `GoogleCalendar` feature extension (`calendar` tool,
`/calendar_connect|status|disconnect`, one Google account per chat keyed by session scope, tokens under
`<agentDir>/google-calendar/`; `docs/google-calendar.md`). The Claude Code provider is the most intricate piece
`<agentDir>/google-calendar/`; `docs/google-calendar.md`); and the `Scheduler` feature extension (a process-wide
`JobScheduler` on `TimeProvider`, `jobs.json` under `<agentDir>/scheduler/`, the per-chat `schedule` tool, watcher
scripts through `IProcessSpawnPolicy`; `docs/telegram-bot.md#scheduling`). The Claude Code provider is the most intricate piece
(`docs/claude-code-provider.md`):
- Every model call spawns a fresh `claude -p` with built-in tools off and replays the whole transcript as stream-json.
@@ -108,6 +112,11 @@ with a local-time header. Files, audio and voice are saved to `<workspace>/inbox
(`Inbound/InboundMedia`), and `Chats/TelegramChatChannel` implements `IChatChannel` for the core `send` tool through
`ChatRenderer.RunInOrderAsync`. The first allowed id is the "owner": `OwnerLoginChannel` sends Claude Code re-login
prompts to that chat. It resolves lazily to break the DI cycle ChatRegistry → session factory → extension → ChatRegistry.
Each chat's time zone lives in `Chats/ChatSettingsStore` (`<agentDir>/telegram/chat-settings.json`, default
`Telegram:DefaultTimeZone`) and is changed through `ChatTimeZoneService` (`/timezone`, the `set_timezone` tool); it
feeds the session's zone and the timestamp header. `Scheduling/TelegramJobDelivery` implements `IJobDelivery` (texts
through the chat's `IChatChannel`, prompts as session follow-ups with the timestamp header, never through the inbox)
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`.
## Conventions

View File

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

View File

@@ -5,6 +5,7 @@
<Project Path="extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.OpenAI/Volcanic.AgenticCore.Extensions.OpenAI.csproj" />
</Folder>
<Folder Name="/hosts/">

View File

@@ -5,7 +5,7 @@
```
src/Volcanic.AgenticCore.Abstractions contracts (no runtime code except helpers)
src/Volcanic.AgenticCore runtime, organised as vertical slices
extensions/* provider and feature extensions (MCP client, Google Calendar), built on the public API only
extensions/* provider and feature extensions (MCP client, Google Calendar, Scheduler), built on the public API only
samples/* reference host
tests/* xunit
```
@@ -21,6 +21,10 @@ directories.
`GoogleCalendar` is a feature extension: a tool and three commands, per-chat accounts through process-wide
`GoogleCalendarConnections` (see [google-calendar.md](google-calendar.md)).
`Scheduler` is a feature extension with a process-wide `JobScheduler` (reminders, scheduled prompts and watcher
scripts on a `TimeProvider` timer, jobs in `<AgentDir>/scheduler/jobs.json`), a per-session `schedule` tool bound to a
host target, and an `IJobDelivery` the host implements (see [telegram-bot.md](telegram-bot.md#scheduling)).
## Vertical slices
The runtime is organised by feature, not by layer. Each folder under `src/Volcanic.AgenticCore/Features`
@@ -55,6 +59,15 @@ is the composition root of one conversation; a host never touches the other slic
Extensions are instantiated per session, so extension fields are per conversation. Provider registrations
also live per session (a `ModelRegistry` per session), which keeps sessions independent.
## Time zone
A session's zone is the first that is set of `SetTimeZone`, `AgentSessionOptions.TimeZone`,
`AgenticCoreOptions.DefaultTimeZone` and `TimeZoneInfo.Local`. It is exposed as `IExtensionContext.TimeZone`, becomes
`TZ` for the session's processes (`PrepareProcessAsync`) and for Claude Code's `claude -p`, and fills the system
prompt's `Current date` / `Time zone` lines. The prompt has the date only and is rendered per run, so a change applies
from the next run and the cached prompt prefix changes at most daily. The session doesn't persist its zone; a host
that lets users change it keeps it (the Telegram host in `chat-settings.json`).
## The agent loop
`PromptAsync(text)`:
@@ -113,9 +126,9 @@ Two independent event systems exist:
## Processes and secrets
Every process started on the agent's behalf (`bash`, `IExtensionApi.ExecAsync`, MCP server processes, which are
prepared through `ctx.PrepareProcessAsync` per session and through `api.Processes` for catalogue discovery, and, with
its spec, scheduler scripts) gets its command, cwd and environment from one `IProcessSpawnPolicy`, configured through
`AgenticCoreOptions.ProcessEnvironment`:
prepared through `ctx.PrepareProcessAsync` per session and through `api.Processes` for catalogue discovery, and
scheduler watcher scripts, through the DI `IProcessSpawnPolicy` since jobs fire without a session) gets its command,
cwd and environment from one `IProcessSpawnPolicy`, configured through `AgenticCoreOptions.ProcessEnvironment`:
```
inherit the host environment → filter (Deny, Allow, SecretValues) → explicit request.Environment (never filtered)
@@ -136,7 +149,7 @@ dumpable.
results, progress updates and error messages (see the loop above), so every tool is covered in one place.
The Claude Code provider's own `claude` processes are outside the policy on purpose: they run the model and already
get an allow-listed environment. None of this is a sandbox: the agent still runs as the host's OS user and can read
get an allow-listed environment (plus the session's `TZ`, set by the provider). None of this is a sandbox: the agent still runs as the host's OS user and can read
the files that user can read.
## Persistence

View File

@@ -37,7 +37,9 @@ conversation is replayed each time.
`--strict-mcp-config --mcp-config <bridge>`, `--input-format stream-json --output-format stream-json
--include-partial-messages --verbose`, `--system-prompt-file <tmp>`, `--model <id>`, `--effort <level>`,
plus settings that disable hooks, auto-memory, plugins, slash commands and the token reminder (which would
defeat prompt caching). Only an allow-list of environment variables is passed through, and compaction
defeat prompt caching). Only an allow-list of environment variables is passed through, plus `TZ` set to
the session's time zone (the chat's zone on Telegram, read per call so a `/timezone` change applies from
the next model call; never the host's own `TZ`, and not on Windows, which ignores it), and compaction
inside Claude is disabled: the core owns compaction. `claude` and its login/doctor processes don't go
through the host's process spawn policy (`AgenticCoreOptions.ProcessEnvironment`): they already get a
replaced, allow-listed environment. The Bridge inherits `claude`'s environment, and the core tools it

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
# Process environment hook, host hardening and secret redaction: keep host secrets away from the agent — design
Date: 2026-09-26
Status: implemented (the MCP spawners in §5 came with 2026-09-26-mcp-client-playwright-design.md; the scheduler `ScriptRunner` comes with its own spec)
Status: implemented (the MCP spawners in §5 came with 2026-09-26-mcp-client-playwright-design.md, the scheduler `ScriptRunner` with 2026-09-26-scheduler-design.md)
Used by: `2026-09-26-scheduler-design.md` (watcher scripts), `2026-09-26-mcp-client-playwright-design.md` (MCP
server processes), `2026-09-26-telegram-attachments-design.md` (`OPENAI_API_KEY`), `2026-09-26-google-calendar-design.md`
(`GOOGLE_CLIENT_SECRET`), `2026-09-26-docker-deployment-design.md` (the secrets set in Coolify).
@@ -231,6 +231,7 @@ Process-wide spawners without a session (the scheduler, MCP catalogue discovery)
| MCP `McpCatalogCache` discovery | as above, `Cwd` = the temp dir | the server's `env` entries | Through `api.Processes.Prepare` (activation, no session). |
*The two MCP rows are implemented in `extensions/Volcanic.AgenticCore.Extensions.Mcp` (`McpServerConnection`, `McpCatalogCache`).*
*The scheduler row is implemented in `extensions/Volcanic.AgenticCore.Extensions.Scheduler` (`ScriptRunner`).*
A spawner whose `Prepare` throws reports it the way it reports a failed start: `bash` returns an error result
`process environment hook failed: <message>`, a watcher is `Failed(null, "…")`, an MCP call or discovery fails with the

View File

@@ -1,7 +1,7 @@
# Scheduler: reminders, scheduled prompts and watcher scripts (design)
Date: 2026-09-26
Status: draft
Status: implemented
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`
@@ -61,7 +61,10 @@ same chat.
- **Scheduled agent prompts are queued as follow-ups, never as steering.** When the chat is idle a prompt job starts a
run. When a run is active (a user request, or another job), the prompt goes in with `StreamingBehavior.FollowUp`: the
current work finishes first, then the job's prompt is handled in the same run. A clock-driven prompt shouldn't
redirect what the user is doing mid-turn. User messages keep steering as they do today.
redirect what the user is doing mid-turn. User messages keep steering as they do today. This also holds when a run
starts between `PromptAsync`'s idle check and its run gate: the core's `StartRunAsync` gets the caller's
`StreamingBehavior`, and a `FollowUp` prompt joins the active run as a follow-up (with a `QueueUpdate`), not as
steering.
- **Cron parsing uses Cronos** (`Cronos` on NuGet, Hangfire, MIT, no dependencies). It parses standard 5-field
expressions and macros (`@daily`, `@hourly`, …). `GetNextOccurrence(DateTimeOffset, TimeZoneInfo)` works in a given
zone and has defined DST semantics: a time skipped when clocks go forward fires at the transition, and an ambiguous
@@ -72,7 +75,10 @@ same chat.
sets a 1-minute floor for watchers.
- **Each chat has its own time zone.** It applies to cron, to displayed times, to the session's system prompt, to the
local-time header on each prompt, and to the `TZ` of the chat's `bash`, watcher and `claude -p` processes (§8). An `at` without an offset is read in the chat's zone, because
models often leave the offset out. An `at` with an offset is used as given. The zone is a chat setting, not a
models often leave the offset out. An `at` with an offset is used as given. The date is required and only ISO 8601 is
accepted (`yyyy-MM-dd`, or `yyyy-MM-dd[T| ]HH:mm[:ss[.fff]]` with an optional `Z`/offset): a bare `09:00` or a culture
format such as `09/10/2026` is rejected with a readable error, because it would be resolved against the machine's
clock rather than the chat's. The zone is a chat setting, not a
scheduler setting, because the system prompt and `bash` need it with the scheduler disabled too:
- **Stored by the host**, in a small per-chat settings store outside every workspace:
`<AgentDir>/telegram/chat-settings.json` (§7). Not in `jobs.json` (the scheduler can be off) and not in the session
@@ -93,7 +99,9 @@ same chat.
fires immediately. If it's more than 60 s late, direct text gets `(due <time>, delivered late)` and an agent prompt
gets `This was due at <time>; the bot was offline.` A cron job's next occurrence is computed strictly after "now".
Occurrences missed while the bot was down are dropped, which is logged at Information. A one-off is removed only
**after** its delivery completes, so a crash mid-fire re-fires it after a restart (at-least-once).
**after** its delivery completes, so a crash mid-fire re-fires it after a restart (at-least-once). The same holds for
a graceful stop: a delivery cut off by shutdown isn't recorded, so the one-off stays in `jobs.json` and fires late on
the next start.
- **Persistence is one central file:** `<AgentDir>/scheduler/jobs.json` (by default `~/.volcanic/agent/scheduler/`).
The workspace is deliberately not used: the agent's `bash` can write anywhere there, and a central file is loaded
without scanning every workspace. The format is `{ "version": 1, "jobs": [...] }` in `VolcanicJson` camelCase.
@@ -153,7 +161,9 @@ watchers. There are at most `MaxJobsPerTarget` (50) jobs per target. Every error
`LoadAsync()` returns the jobs. A missing file means an empty list. A corrupt file is renamed to
`jobs.json.corrupt-<timestamp>`, logged as an error, and loading starts empty: a bad file must not keep the host down.
Invalid jobs are dropped with a warning. `SaveAsync(jobs)` writes `jobs.json.<pid>.tmp` and then does
Invalid jobs are dropped with a warning. A stored cron job whose next occurrence can't be computed on start (for
example a target the host can't map to a zone) is logged and left unarmed; it never fails `StartAsync`. The Telegram
delivery's `TimeZoneFor` returns the default zone for a target that isn't a chat id. `SaveAsync(jobs)` writes `jobs.json.<pid>.tmp` and then does
`File.Move(overwrite: true)`. Writes go through one `SemaphoreSlim`.
### 3. `ScriptRunner` (`IScriptRunner`, faked in tests)
@@ -163,16 +173,20 @@ Invalid jobs are dropped with a warning. `SaveAsync(jobs)` writes `jobs.json.<pi
- The shell matches the built-in `bash` tool, so a script the agent tested with `bash` behaves the same when it's
scheduled: `/bin/sh -c <command>` on Unix and `cmd.exe /d /s /c` on Windows. It's overridable through
`SchedulerOptions.Shell`. The command is passed through `ArgumentList`, not quoted by hand.
- The working directory is the target's workspace. Stdin is closed.
`SchedulerOptions.Shell`. On Unix the command is passed through `ArgumentList`, not quoted by hand. On Windows
`cmd.exe` doesn't parse `ArgumentList`'s quoting, so the command goes through `Arguments` as
`/d /s /c "<command>"`, exactly as the `bash` tool does. A configured `SchedulerOptions.Shell` always uses
`ArgumentList`.
- The working directory is the target's workspace (created if missing). Stdin is closed.
- The command, cwd and environment come from `IProcessSpawnPolicy.Prepare` (DI singleton, registered by
`AddAgenticCore`) with `Purpose = ScheduledScript`, `Scope = target`, `Origin = job id` and `TimeZoneId` = the
target's zone. So the script sees the same filtered environment as `bash`, with `TZ` set to the chat's zone, and
host hooks apply. There's no scheduler-specific scrub list any more (`SchedulerOptions.ScrubbedEnvironment` is
removed). A policy that throws is `Failed(null, "process environment hook failed: …")`. Session-level
`ProcessSpawnEvent` handlers don't run: a job fires without a live session.
- Timeout or cancellation → `Process.Kill(entireProcessTree: true)`. A shell that fails to start is `Failed(null, "failed to start <shell>: …")`,
never an exception.
- Timeout or cancellation → `Process.Kill(entireProcessTree: true)`. A timeout is `Failed(null, stderrTail, timedOut: true)`;
a cancellation throws `OperationCanceledException` after the kill. A shell that fails to start (or a workspace that
can't be created) is `Failed(null, "failed to start <shell>: …")`, never an exception.
- Output capture as in the reference, then `Trim()` on stdout.
### 4. `JobScheduler` (process-wide, the slice's entry type)
@@ -185,6 +199,7 @@ public sealed class JobScheduler(SchedulerOptions options, IScriptRunner runner,
IReadOnlyList<JobView> List(string target); // JobView = job + NextRunAt (null = never)
Task<JobView> CreateAsync(string target, NewJob job, CancellationToken ct); // throws JobValidationException
Task<bool> CancelAsync(string target, string id, CancellationToken ct); // false: unknown, or another target's
Task<ScheduledJob?> CancelJobAsync(string target, string id, CancellationToken ct); // the removed job, in one step (tool, /cancel)
TimeZoneInfo TimeZoneFor(string target); // delivery.TimeZoneFor
Task<ZoneChange> TimeZoneChangedAsync(string target, CancellationToken ct); // recompute cron, re-arm; counts for the reply
}
@@ -195,11 +210,11 @@ public interface IJobDelivery
Task SendTextAsync(string target, string markdown, CancellationToken ct);
/// <summary>Run a prompt in the target's session (follow-up when busy); completes when the run that consumed it settles.</summary>
Task RunPromptAsync(string target, string prompt, CancellationToken ct);
/// <summary>Working directory for the target's scripts.</summary>
/// <summary>Working directory for the target's scripts. A path computation: must not create or wake a session.</summary>
string WorkspaceFor(string target);
/// <summary>False when the target may no longer be served (e.g. removed from the allow-list): the job is skipped, not deleted.</summary>
/// <summary>False when the target may no longer be served (e.g. removed from the allow-list): the job is skipped, not deleted. Must not create or wake a session.</summary>
bool IsDeliverable(string target);
/// <summary>The target's current zone (the chat's, else the host default). Read on every computation, never cached.</summary>
/// <summary>The target's current zone (the chat's, else the host default). Read on every computation, never cached. Must not create or wake a session.</summary>
TimeZoneInfo TimeZoneFor(string target);
}
```
@@ -211,15 +226,16 @@ Firing (`FireAsync(job)`, tracked in an in-flight set and never awaited by the t
| Kind | Action | Message format |
|---|---|---|
| reminder | `SendTextAsync` | `⏰ <text>` |
| prompt | `RunPromptAsync` | `[Scheduled job "<name>" (<id>)]\n<text>` |
| reminder | `SendTextAsync` | `⏰ <text>`; fired more than 60 s late: `⏰ <text> (due <d MMM HH:mm>, delivered late)` |
| prompt | `RunPromptAsync` | `[Scheduled job "<name>" (<id>)]\n<text>`; fired more than 60 s late, the note gets its own line after the header: `[Scheduled job "<name>" (<id>)]\nThis was due at <d MMM HH:mm>; the bot was offline.\n<text>` |
| watcher, matched (exit 0, non-empty stdout) | `RunPromptAsync` | `[Scheduled job "<name>" (<id>)] The watcher script printed:\n<stdout>` |
| watcher, silent (exit 0, empty stdout) | nothing: no model call, no session activity, no transcript entry | only `LastRunAt`/`LastResult` are updated |
| watcher, failed | `SendTextAsync` once per failure streak | `⚠️ Job "<name>" (<id>) failed (exit <n>[, timed out]):\n```\n<stderr tail>\n```` |
| watcher, failed | `SendTextAsync` once per failure streak | `⚠️ Job "<name>" (<id>) failed (exit <n>[, timed out]):\n```\n<stderr tail>\n````; with no exit code it reads `failed (timed out)` or `failed (did not start)` |
- **Watcher invariant.** Running a watcher's script touches only `IScriptRunner`, `WorkspaceFor` and `TimeZoneFor`.
- Watcher matches never get a late note (a watcher only runs when its occurrence comes due; there's no catch-up).
- **Watcher invariant.** Running a watcher's script touches only `IScriptRunner`, `IsDeliverable`, `WorkspaceFor` and `TimeZoneFor`.
It never calls `RunPromptAsync` or `SendTextAsync`, and the Telegram delivery's `WorkspaceFor`/`TimeZoneFor` don't
create or look up a chat session. Only the outcome decides what follows: `Output` → exactly one `RunPromptAsync`,
create or look up a chat session (nor does `IsDeliverable`). Only the outcome decides what follows: `Output` → exactly one `RunPromptAsync`,
`Failed` → at most one `SendTextAsync` per failure streak, `Silent` → nothing.
- **Redaction.** A watcher's stdout goes through `redactor.Redact` before it's put into the prompt, and the stderr
tail before it's put into the failure alert (direct to the chat, but still not a place for the bot token). Known
@@ -233,7 +249,9 @@ Firing (`FireAsync(job)`, tracked in an in-flight set and never awaited by the t
throws `ChatDeliveryException`. The job's state still advances: there's no redelivery queue.
- `CancelAsync` removes the job. If that job's watcher script is running it's killed and its outcome thrown away. A
prompt run that's in progress isn't aborted: that's what `/stop` is for.
- `!IsDeliverable(target)` at fire time → skip, log a warning, keep the job (a cron job advances).
- `!IsDeliverable(target)` at fire time → skip, log a warning, keep the job (a cron job advances); a skipped one-off
stays in the store and fires again after the next start.
- Saves are serialised and each one snapshots the jobs inside that gate, so a save never overwrites newer state.
- `TimeZoneChangedAsync(target)` recomputes `NextRunAt` for the target's cron jobs in the new zone (strictly after now,
no catch-up), leaves one-offs untouched, re-arms the timer and returns
`ZoneChange(int CronJobs, int OneOffs)`. A cron occurrence already in flight isn't affected.
@@ -267,7 +285,8 @@ schedule(action: create|list|cancel, id?, name?, kind?: reminder|prompt|watcher,
- **Options**: a new `Scheduler` section bound to `SchedulerOptions`: `Enabled` (true),
`StorePath` (`<AgentDir>/scheduler/jobs.json`), `DefaultScriptTimeout` (300 s), `MaxJobsPerTarget` (50) and
`Shell`. There's no time zone and no scrub list here: zones are per chat (§7), the environment comes from
the process environment hook.
the process environment hook. An empty `StorePath` means `<AgentDir>/scheduler/jobs.json`; `Validate` rejects a
`DefaultScriptTimeout` outside 1 s–1 h, `MaxJobsPerTarget` < 1 and empty `Shell` entries (exit 2).
- **`Program.cs`** registers `SchedulerOptions`, `IScriptRunner` → `ScriptRunner`, `TimeProvider.System` (one
`TryAddSingleton`, shared with the message-context and attachments specs), `JobScheduler`,
`TelegramJobDelivery`, `ChatSettingsStore`, `ChatTimeZoneService` (§7; registered even when the scheduler is
@@ -295,13 +314,15 @@ schedule(action: create|list|cancel, id?, name?, kind?: reminder|prompt|watcher,
```
**Scheduled jobs** (Europe/Berlin, now 26 Sep 14:03)
`k7m2q` ⏰ Call the dentist · once 27 Sep 09:00
`p3xz9` 💬 Inbox summary · `0 8 * * 1-5` · next 29 Sep 08:00
`p3xz9` 💬 Inbox summary · `0 8 * * 1-5` · next 28 Sep 08:00
`w8r4t` 🔍 Price check · `*/10 * * * *` · next 14:10 · last: failed (exit 1)
```
The header and every time are in the chat's zone.
- `/cancel <id>` → `Cancelled k7m2q (Call the dentist).` `No job k7m2q in this chat.` also covers another chat's id.
With no id: `Usage: /cancel <id> (see /jobs).`
- `/status` gains `**Jobs:** <n>`.
- With the scheduler disabled, `/jobs` and `/cancel` stay in the menu and reply
`Scheduling is turned off on this bot (Scheduler:Enabled).`; `/status` has no Jobs line.
- **Security**: scheduled scripts run unattended as the bot's OS user, with the same trust as the `bash` tool that
every allow-listed user already has, and the same filtered environment. The existing Security section in
`docs/telegram-bot.md` gains that sentence.
@@ -336,6 +357,8 @@ schedule(action: create|list|cancel, id?, name?, kind?: reminder|prompt|watcher,
changed; confirm the new zone in your reply.` Errors from `TryParse` become error results. It runs whether or not the
scheduler is enabled.
- `/status` gains `**Time zone:** <id> (<offset>)`.
- `ChatTimeZoneService` finds the live session through a function (`ChatRegistry.Find`) so DI has no ChatRegistry ↔
service cycle; with no scheduler, `/timezone` replies without the job clause.
### 8. Session time zone (core)
@@ -374,7 +397,11 @@ and Claude Code's `TZ`:
the allow-list, like its pinned `CLAUDE_CODE_*` flags. Anything Claude Code itself adds about the date then agrees
with our prompt. It isn't set on Windows (the OS ignores `TZ`, as in the process-environment-hook spec §3), nor for
`claude auth login`/`auth status` and the doctor's probes, which have no session. `TZ` stays off the inherited
allow-list: the host's own `TZ` never leaks through. This goes through the provider, not `IProcessSpawnPolicy`
allow-list: the host's own `TZ` never leaks through. When the session has no configured zone, `claude -p` gets the
IANA id of `TimeZoneInfo.Local` (it can't inherit `TZ`) while `bash` inherits the host's `TZ` (its `TimeZoneId` is
null); both end up in the host's zone. All three id conversions follow one rule, `TimeZoneIds.ToIana` (mirrored by
`ClaudeArgs.IanaZoneId` and the scheduler's `ZoneNames.Iana`, since extensions can't reference the core): null unless
the id resolves to a system zone, so an unnamed `Local` sets no `TZ`. This goes through the provider, not `IProcessSpawnPolicy`
(process-environment-hook spec §7). `docs/claude-code-provider.md` (its environment section) is updated with the
implementation.
- Headless sample: no change; it gets `TimeZoneInfo.Local` as before, now shown in the prompt.

View File

@@ -4,9 +4,8 @@ Date: 2026-09-26
Status: implemented
Depends on: `2026-09-26-telegram-attachments-design.md`, which plugs its content steps (files, speech) into
`InboundComposer` and uses the renderer's `RunInOrderAsync` for notices and the `🎤` echo.
Also `2026-09-26-scheduler-design.md` §7 (`ChatSettingsStore`, the chat's time zone for the timestamp header; if this spec
lands first, the header uses the session's zone (`AgentSession.TimeZone`, which falls back to `TimeZoneInfo.Local`)
through `ChatRegistry.TimeZoneFor` until the store exists).
Also `2026-09-26-scheduler-design.md` §7 (`ChatSettingsStore`, the chat's time zone for the timestamp header; the
header's zone comes from `ChatRegistry.TimeZoneFor`, which returns `ChatSettingsStore.TimeZoneFor(chatId)`).
## Problem
@@ -224,8 +223,7 @@ on `/new`, `/stop` and a `/resume` that names exactly one conversation, before i
`TimeProvider.System`; `Program.cs` registers `TimeProvider.System` with `TryAddSingleton`), and an
`onFlush` that:
1. renders the parts to `(text, images)`, with the timestamp header in the chat's current zone
(`ChatRegistry.TimeZoneFor(chatId)`, which returns the chat session's `TimeZone` until the scheduler spec's
`ChatSettingsStore` exists and then delegates to `ChatSettingsStore.TimeZoneFor(chatId)`; read at flush time, so
(`ChatRegistry.TimeZoneFor(chatId)`, which returns `ChatSettingsStore.TimeZoneFor(chatId)`; read at flush time, so
a `/timezone` change applies to the next batch). An image-only batch still gets the header line as its text;
2. if `session.IsStreaming`, calls `sender.ReactAsync(chatId, id, "👀")` for each part's message id, fire-and-forget;
3. calls `chat.PromptInBackground(text, images)`.

View File

@@ -23,7 +23,7 @@ Open the bot in Telegram and say hello.
## Configuration
Environment variables, or the `Telegram` section of `appsettings.json` (`Telegram__Name` in the environment; the Google
settings use the `GoogleCalendar` section):
settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section):
| Setting | Default | Notes |
|---|---|---|
@@ -34,6 +34,12 @@ settings use the `GoogleCalendar` section):
| `Telegram:EditInterval` | `00:00:01.5` | Minimum time between edits of the "Working..." status message |
| `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:StorePath` | `<agent dir>/scheduler/jobs.json` | One file for every chat's jobs, outside the workspaces |
| `Scheduler:DefaultScriptTimeout` | `00:05:00` | Watcher timeout when a job sets none (1 s to 1 h) |
| `Scheduler:MaxJobsPerTarget` | `50` | Jobs per chat |
| `Scheduler:Shell` | `/bin/sh -c` (`cmd.exe /d /s /c` on Windows) | The watcher shell as a list, command appended; same as `bash` by default |
| `OPENAI_API_KEY` / `Telegram:Speech:ApiKey` | — | Turns voice transcription on. Hidden from `bash` and redacted from tool output |
| `STT_MODEL` / `Telegram:Speech:Model` | `gpt-4o-transcribe` | `gpt-4o-mini-transcribe` and `whisper-1` also work |
| `Telegram:Speech:BaseUrl` | `https://api.openai.com/v1` | Any OpenAI-compatible transcription endpoint |
@@ -49,7 +55,8 @@ settings use the `GoogleCalendar` section):
| `Mcp:Servers:<name>` | — | Other stdio MCP servers ([mcp.md](mcp.md#configuration)) |
Sessions are stored as usual under `~/.volcanic/agent/sessions/telegram-<chatId>/`; after a restart each chat resumes
its most recent conversation.
its most recent conversation. Each chat's time zone is kept in `<agent dir>/telegram/chat-settings.json`, so it
survives `/new` and restarts.
## Using it
@@ -73,7 +80,7 @@ Along with your text, the agent sees:
- **Replies and quotes.** Reply to one of the bot's messages (or one of yours), or quote part of it, and the agent gets
a line such as `[Reply to your message: "Which plant do you mean?"]` before your text.
- **Forwards**, labelled with their source: `[Forwarded from Daily News (Anna K.)]`.
- **The local time** you sent the message, in the chat's time zone (for now, the zone of the machine running the bot):
- **The local time** you sent the message, in the chat's time zone (`/timezone`; by default `Telegram:DefaultTimeZone`, else the zone of the machine running the bot):
`[Sat 2026-09-26 14:03 Europe/Berlin]`, once per request.
Messages sent within about 0.6 s of each other (`Telegram:InputDebounce`), such as a forward with your comment or
@@ -88,10 +95,13 @@ started a chat with the bot, Telegram refuses the message; the bot logs that and
|---|---|
| `/new` | Start a new conversation |
| `/stop` | Stop the current run |
| `/status` | Model, thinking level, session, context usage, workspace |
| `/status` | Model, thinking level, session, context usage, time zone, number of scheduled jobs, workspace |
| `/sessions`, `/resume <id>` | List and resume earlier conversations (an id prefix is enough) |
| `/compact` | Summarise the conversation to free context |
| `/thinking [level]` | Show or set the thinking level (Claude Code `--effort`) |
| `/timezone [name\|reset]` | Show or set this chat's time zone (IANA name such as `Asia/Tokyo`; `reset` returns to the default) |
| `/jobs` | This chat's scheduled jobs with their next run |
| `/cancel <id>` | Cancel one of them |
| `/help` | Short help |
| `/calendar_connect` | Connect this chat's Google Calendar (link and code; the bot confirms when you approve) |
| `/calendar_status` | The connected account and time zone, or the pending code |
@@ -135,6 +145,44 @@ as photos (up to 10 MB), other files as documents (up to 50 MB), with an optiona
photo Telegram rejects (for example for its dimensions) is resent as a document. `send` accepts only files inside the
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:
- "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.
- "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
script itself, **without the agent**:
- exit 0 with no output means "nothing yet" and costs nothing: no model call, nothing in the conversation;
- exit 0 with output means the condition is met: the output always goes to the agent, which writes you the message
(one model call per match);
- a non-zero exit or a timeout is reported once, straight to the chat, with the last 20 lines of the script's errors.
Later identical failures stay quiet until the script succeeds again.
Scripts run in the chat's workspace with the same filtered environment as `bash` and `TZ` set to the chat's zone.
Known secrets are redacted from their output.
When a job fires while the agent is working, a scheduled prompt waits until the current work is done; it never
interrupts. Occurrences that come due while the same job is still running are skipped.
Times are in the chat's zone. `/timezone Asia/Tokyo` (or telling the agent where you are; it has a `set_timezone`
tool) changes it for the date in the agent's instructions, `bash`, scheduled scripts, message timestamps and Claude
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:
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
`Scheduler:Enabled=false`, `/jobs` and `/cancel` reply that scheduling is turned off, and `/timezone` still works.
### Google Calendar
Each chat connects its own Google account with `/calendar_connect`, and the agent's `calendar` tool only uses that
@@ -148,6 +196,9 @@ The agent has the built-in tools, including `bash`, and runs them as the bot's O
are served (everyone else is ignored and logged), and only in private chats. Run the bot as a dedicated user or in a
container, and treat the workspace root as writable by anyone on the allow-list.
Scheduled watcher scripts run unattended as the bot's OS user, with the same trust as the `bash` tool that every
allow-listed user already has, and the same filtered environment.
`send` refuses paths outside the chat's workspace, symlinks included, so a prompt-injected "send ~/.ssh/id_rsa" fails
visibly (it is no barrier against `bash cp`). The speech API key is kept out of the agent's processes like the bot
token.

View File

@@ -40,6 +40,9 @@ public static class ClaudeArgs
public const string ReadyVariable = "VOLCANIC_CLAUDE_TOOL_READY";
public const string MaxOutputTokensVariable = "CLAUDE_CODE_MAX_OUTPUT_TOKENS";
/// <summary>The session's zone for the child (never inherited from the parent: <c>TZ</c> isn't on the allow-list).</summary>
public const string TimeZoneVariable = "TZ";
private static readonly JsonSerializerOptions ConfigOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
/// <summary>Variables forwarded from the parent environment (compared case-insensitively). <c>LC_*</c> is forwarded as a prefix.</summary>
@@ -143,16 +146,32 @@ public static class ClaudeArgs
}
/// <summary>
/// The complete environment a Claude child receives: the allow-listed parent variables plus the pinned ones.
/// The complete environment a Claude child receives: the allow-listed parent variables, the pinned ones, and
/// <c>TZ</c> = <paramref name="timeZoneId"/> (the session's zone; not on Windows, which ignores it).
/// Throws <c>auth</c> when the parent process carries API-key or third-party-provider authentication.
/// </summary>
public static IReadOnlyDictionary<string, string> BuildEnvironment(IReadOnlyDictionary<string, string?> parentEnv, int maxOutputTokens)
public static IReadOnlyDictionary<string, string> BuildEnvironment(IReadOnlyDictionary<string, string?> parentEnv, int maxOutputTokens, string? timeZoneId = null)
{
ArgumentNullException.ThrowIfNull(parentEnv);
if (FindDeniedVariable(parentEnv) is { } denied)
throw new ClaudeCodeException(ClaudeCodeException.Codes.Auth,
$"{denied} is set; the Claude Code provider only supports subscription authentication and never forwards API-key or third-party credentials. Unset it to use this provider");
return AllowListed(parentEnv, maxOutputTokens);
var env = new Dictionary<string, string>(AllowListed(parentEnv, maxOutputTokens), StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(timeZoneId) && !OperatingSystem.IsWindows()) env[TimeZoneVariable] = timeZoneId;
return env;
}
/// <summary>
/// The IANA id for <c>TZ</c>: the zone's own id or a converted Windows id, or null when there is none that resolves
/// back to a system zone (an unnamed <c>Local</c> zone). Mirrors the core's <c>TimeZoneIds.ToIana</c> (the extension
/// references only the Abstractions); keep the two in step.
/// </summary>
public static string? IanaZoneId(TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(zone);
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
return id is not null && TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? id : null;
}
/// <summary>The allow-listed environment without the denial check (used by diagnostics that only probe the CLI).</summary>

View File

@@ -28,6 +28,13 @@ public sealed record ClaudeCodeApiOptions
/// <summary>The environment the allow-list is applied to; the process environment when null.</summary>
public IReadOnlyDictionary<string, string?>? ParentEnvironment { get; init; }
/// <summary>
/// The IANA zone for the child's <c>TZ</c>, read per request (the extension reads the session's
/// <c>IExtensionContext.TimeZone</c>, so <c>SetTimeZone</c> applies from the next model call). Null or a null result
/// leaves <c>TZ</c> unset.
/// </summary>
public Func<string?>? TimeZoneId { get; init; }
/// <summary>The MCP server name the bridge is registered under (tools become <c>mcp__&lt;name&gt;__&lt;alias&gt;</c>).</summary>
public string ServerName { get; init; } = ToolCatalog.DefaultServerName;
@@ -250,7 +257,7 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
var stdin = ContextSerializer.BuildStdinMessage(context, catalog, images, cacheBreakpoint: streamOptions.CacheRetention != CacheRetention.None);
var argv = ClaudeArgs.Build(new ClaudeArgsInput(model.Id, effort, systemPromptPath, catalogPath, violationPath, readyPath, Options.BridgeDllPath, Options.DotnetPath, Options.ServerName));
var env = ClaudeArgs.BuildEnvironment(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens);
var env = ClaudeArgs.BuildEnvironment(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens, Options.TimeZoneId?.Invoke());
if (streamOptions.OnPayload is not null)
{

View File

@@ -39,6 +39,7 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
private readonly ClaudeCodeProviderOptions _options = options ?? new ClaudeCodeProviderOptions();
private string? _ownSessionId;
private IExtensionContext? _context;
public string Name => ExtensionName;
@@ -60,6 +61,7 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
LoginUi = _options.LoginUi,
Coordinator = _options.Coordinator,
LoginOptions = _options.LoginOptions,
TimeZoneId = CurrentZoneId,
};
var claude = new ClaudeCodeApi(registry, apiOptions);
Api = claude;
@@ -83,6 +85,7 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
// so the second registration does not orphan the first.
if (_ownSessionId is not null && _ownSessionId != ctx.SessionId) registry.Remove(_ownSessionId);
_ownSessionId = ctx.SessionId;
_context = ctx;
registry.Register(ctx.SessionId, ctx.Cwd, ctx.HasUi ? ctx.Ui.Notify : null);
if (apiOptions.ClaudePath is null && ctx.HasUi)
ctx.Ui.Notify($"[claude-code] Claude Code (claude) was not found; run /{DoctorCommand.Name} for details", NotifyKind.Warning);
@@ -92,7 +95,11 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
api.On<SessionShutdownEvent>((_, ctx, _) =>
{
registry.Remove(ctx.SessionId);
if (_ownSessionId == ctx.SessionId) _ownSessionId = null;
if (_ownSessionId == ctx.SessionId)
{
_ownSessionId = null;
_context = null;
}
return ValueTask.CompletedTask;
});
@@ -109,4 +116,17 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
return ValueTask.CompletedTask;
}
/// <summary>The session's zone as an IANA id, or null without a live session (e.g. a context gone stale after dispose).</summary>
private string? CurrentZoneId()
{
try
{
return _context is { } ctx ? ClaudeArgs.IanaZoneId(ctx.TimeZone) : null;
}
catch (Exception)
{
return null;
}
}
}

View File

@@ -0,0 +1,20 @@
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>What the host does for the scheduler. Passed to <see cref="JobScheduler.StartAsync"/>, not the constructor, to break the host's DI cycle.</summary>
public interface IJobDelivery
{
/// <summary>Send Markdown to the target's chat, in order with that chat's rendered output.</summary>
Task SendTextAsync(string target, string markdown, CancellationToken cancellationToken);
/// <summary>Run a prompt in the target's session (follow-up when busy); completes when the run that consumed it settles.</summary>
Task RunPromptAsync(string target, string prompt, CancellationToken cancellationToken);
/// <summary>Working directory for the target's scripts. A path computation: must not create or wake a session.</summary>
string WorkspaceFor(string target);
/// <summary>False when the target may no longer be served (e.g. removed from the allow-list): the job is skipped, not deleted. Must not create or wake a session.</summary>
bool IsDeliverable(string target);
/// <summary>The target's current zone (the chat's, else the host default). Read on every computation, never cached. Must not create or wake a session.</summary>
TimeZoneInfo TimeZoneFor(string target);
}

View File

@@ -0,0 +1,53 @@
using System.Globalization;
using System.Text;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>How jobs and times are shown (the <c>schedule</c> tool, <c>/jobs</c>, zone-change replies). Invariant culture, 24 h.</summary>
public static class JobFormat
{
public static string Icon(JobKind kind) => kind switch { JobKind.Reminder => "⏰", JobKind.Prompt => "💬", _ => "🔍" };
/// <summary><c>27 Sep 09:00</c> in <paramref name="zone"/>.</summary>
public static string DayTime(DateTimeOffset at, TimeZoneInfo zone) =>
TimeZoneInfo.ConvertTime(at, zone).ToString("d MMM HH:mm", CultureInfo.InvariantCulture);
/// <summary><c>14:10</c> when on the same local day as <paramref name="now"/>, else <see cref="DayTime"/>.</summary>
public static string When(DateTimeOffset at, TimeZoneInfo zone, DateTimeOffset now)
{
var local = TimeZoneInfo.ConvertTime(at, zone);
return local.Date == TimeZoneInfo.ConvertTime(now, zone).Date
? local.ToString("HH:mm", CultureInfo.InvariantCulture)
: DayTime(at, zone);
}
/// <summary><c>Sat 2026-09-26 14:03</c> (the model-facing form: weekday and full date).</summary>
public static string Stamp(DateTimeOffset at, TimeZoneInfo zone) =>
TimeZoneInfo.ConvertTime(at, zone).ToString("ddd yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
public static string ZoneName(TimeZoneInfo zone) => ZoneNames.Name(zone);
/// <summary><c>exit 1</c>, <c>exit 1, timed out</c>, <c>timed out</c>, or <c>did not start</c>.</summary>
public static string FailureReason(JobResult result) => result switch
{
{ ExitCode: { } code, TimedOut: true } => $"exit {code.ToString(CultureInfo.InvariantCulture)}, timed out",
{ ExitCode: { } code } => $"exit {code.ToString(CultureInfo.InvariantCulture)}",
{ TimedOut: true } => "timed out",
_ => "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>
public static string Line(JobView view, TimeZoneInfo zone, DateTimeOffset now)
{
var job = view.Job;
var sb = new StringBuilder().Append('`').Append(job.Id).Append("` ").Append(Icon(job.Kind)).Append(' ').Append(job.Name);
if (job.Schedule.At is { } at) sb.Append(" · once ").Append(DayTime(at, zone));
else
{
sb.Append(" · `").Append(job.Schedule.Cron).Append('`');
if (view.NextRunAt is { } next) sb.Append(" · next ").Append(When(next, zone, now));
}
if (job.LastResult is { Ok: false } failed) sb.Append(" · last: failed (").Append(FailureReason(failed)).Append(')');
return sb.ToString();
}
}

View File

@@ -0,0 +1,18 @@
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>The texts a fire sends (spec §4 table).</summary>
internal static class JobMessages
{
public static string Reminder(ScheduledJob job, DateTimeOffset? lateDue, TimeZoneInfo zone) =>
$"⏰ {job.Text}" + (lateDue is { } due ? $" (due {JobFormat.DayTime(due, zone)}, delivered late)" : "");
public static string Prompt(ScheduledJob job, DateTimeOffset? lateDue, TimeZoneInfo zone) =>
$"{Header(job)}\n" + (lateDue is { } due ? $"This was due at {JobFormat.DayTime(due, zone)}; the bot was offline.\n" : "") + job.Text;
public static string WatcherMatch(ScheduledJob job, string output) => $"{Header(job)} The watcher script printed:\n{output}";
public static string Failure(ScheduledJob job, JobResult result) =>
$"⚠️ Job \"{job.Name}\" ({job.Id}) failed ({JobFormat.FailureReason(result)}):\n```\n{(string.IsNullOrWhiteSpace(result.StderrTail) ? "(no output on stderr)" : result.StderrTail)}\n```";
private static string Header(ScheduledJob job) => $"[Scheduled job \"{job.Name}\" ({job.Id})]";
}

View File

@@ -0,0 +1,498 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>
/// The process-wide scheduler: loads <c>jobs.json</c>, arms one <see cref="TimeProvider"/> timer for the soonest job
/// (capped at 1 h, re-armed on every tick, create, cancel, fire and zone change), and fires jobs through the host's
/// <see cref="IJobDelivery"/>. A fire is tracked in flight and never awaited by the tick; an occurrence that comes due
/// while the job is still in flight is skipped. A watcher's script run touches only the runner, <c>IsDeliverable</c>,
/// <c>WorkspaceFor</c> and <c>TimeZoneFor</c>: a silent run makes no delivery call at all.
/// </summary>
public sealed class JobScheduler : IAsyncDisposable
{
public static readonly TimeSpan MaxTimerDelay = TimeSpan.FromHours(1);
public static readonly TimeSpan LateThreshold = TimeSpan.FromSeconds(60);
public static readonly TimeSpan StopGrace = TimeSpan.FromSeconds(10);
private const string IdAlphabet = "abcdefghjkmnpqrstuvwxyz23456789";
private readonly SchedulerOptions _options;
private readonly IScriptRunner _runner;
private readonly ISecretRedactor _redactor;
private readonly TimeProvider _time;
private readonly ILogger _logger;
private readonly JobStore _store;
private readonly SemaphoreSlim _saveGate = new(1, 1);
private readonly Lock _lock = new();
private readonly Dictionary<string, Entry> _jobs = new(StringComparer.Ordinal);
private readonly Dictionary<string, Fire> _inFlight = new(StringComparer.Ordinal);
private readonly CancellationTokenSource _stopping = new();
private IJobDelivery? _delivery;
private ITimer? _timer;
private DateTimeOffset? _wakeAt;
private bool _stopped;
private Task _pendingTick = Task.CompletedTask;
private int _firesStarted;
public JobScheduler(SchedulerOptions options, IScriptRunner runner, ISecretRedactor redactor, TimeProvider time, ILogger<JobScheduler> logger)
{
ArgumentNullException.ThrowIfNull(options);
if (string.IsNullOrWhiteSpace(options.StorePath)) throw new ArgumentException("SchedulerOptions.StorePath is required.", nameof(options));
_options = options;
_runner = runner;
_redactor = redactor;
_time = time;
_logger = logger;
_store = new JobStore(options.StorePath, time, logger);
}
public bool IsStarted { get { lock (_lock) return _delivery is not null && !_stopped; } }
public DateTimeOffset Now => _time.GetUtcNow();
public string StorePath => _store.Path;
/// <summary>When the timer fires next (tests).</summary>
internal DateTimeOffset? NextWakeAt { get { lock (_lock) return _wakeAt; } }
/// <summary>How many fires have been started (tests: a skipped occurrence doesn't count).</summary>
internal int FiresStarted { get { lock (_lock) return _firesStarted; } }
/// <summary>The latest queued tick, without the fires it started (tests; unlike <see cref="IdleAsync"/> it doesn't wait for held fires).</summary>
internal Task PendingTick { get { lock (_lock) return _pendingTick; } }
/// <summary>Load the store, fire overdue one-offs (late), compute cron occurrences strictly after now (no catch-up), arm the timer.</summary>
public async Task StartAsync(IJobDelivery delivery, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(delivery);
var jobs = await _store.LoadAsync(cancellationToken).ConfigureAwait(false);
var now = _time.GetUtcNow();
lock (_lock)
{
if (_delivery is not null || _stopped) throw new InvalidOperationException("The scheduler was already started.");
_delivery = delivery;
foreach (var job in jobs)
{
var entry = new Entry(job);
if (job.Schedule.At is { } at) entry.Next = at;
else
{
try
{
entry.Next = NextCron(job, now);
var missed = Schedules.NextCron(job.Schedule.Cron!, job.LastRunAt ?? job.CreatedAt, delivery.TimeZoneFor(job.Target));
if (missed is { } m && m < now)
_logger.LogInformation("Job {Id} ({Name}): occurrences missed while the bot was down are skipped; next {Next}", job.Id, job.Name, entry.Next);
}
catch (Exception ex)
{
// One bad entry never keeps the host down: it stays listed but unarmed until a zone change recomputes it.
entry.Next = null;
_logger.LogError(ex, "Job {Id} ({Name}): computing the next occurrence on start failed; the job isn't armed", job.Id, job.Name);
}
}
_jobs[job.Id] = entry;
}
_timer = _time.CreateTimer(_ => OnTimer(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
}
Tick(); // overdue one-offs fire now
}
/// <summary>Disarm, kill running scripts, and wait at most <see cref="StopGrace"/> for in-flight fires.</summary>
public async Task StopAsync(CancellationToken cancellationToken)
{
Fire[] fires;
ITimer? timer;
lock (_lock)
{
if (_stopped) return;
_stopped = true;
fires = [.. _inFlight.Values];
timer = _timer;
}
timer?.Dispose();
await _stopping.CancelAsync().ConfigureAwait(false); // every fire's token is linked to this one
try
{
await Task.WhenAll(fires.Select(f => f.Task)).WaitAsync(StopGrace, _time, cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException)
{
_logger.LogWarning("Scheduler stop: {Count} job(s) still running after {Grace}", fires.Count(f => !f.Task.IsCompleted), StopGrace);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
}
public ValueTask DisposeAsync() => new(StopAsync(CancellationToken.None));
public IReadOnlyList<JobView> List(string target)
{
lock (_lock)
return _jobs.Values.Where(e => e.Job.Target == target)
.Select(e => new JobView(e.Job, e.Next))
.OrderBy(v => v.NextRunAt ?? DateTimeOffset.MaxValue).ThenBy(v => v.Job.CreatedAt)
.ToList();
}
public TimeZoneInfo TimeZoneFor(string target) => Volatile.Read(ref _delivery)?.TimeZoneFor(target) ?? TimeZoneInfo.Local;
/// <summary>Validate (an offset-less <c>at</c> is read in the target's zone), persist, arm. Throws <see cref="JobValidationException"/>.</summary>
public async Task<JobView> CreateAsync(string target, NewJob job, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(job);
if (!IsStarted) throw new InvalidOperationException("The scheduler isn't running.");
var zone = TimeZoneFor(target);
var now = _time.GetUtcNow();
var schedule = JobValidation.ValidateNew(job, now, zone);
ScheduledJob created;
DateTimeOffset? next;
lock (_lock)
{
if (_jobs.Values.Count(e => e.Job.Target == target) >= _options.MaxJobsPerTarget)
throw new JobValidationException($"This chat already has {_options.MaxJobsPerTarget} scheduled jobs, the maximum. Cancel one first.");
created = new ScheduledJob
{
Id = NewId(), Target = target, Name = job.Name.Trim(), Kind = job.Kind, Schedule = schedule,
Text = job.Kind == JobKind.Watcher ? null : job.Text, Command = job.Kind == JobKind.Watcher ? job.Command : null,
TimeoutSeconds = job.TimeoutSeconds, CreatedAt = now,
};
next = schedule.At ?? Schedules.NextCron(schedule.Cron!, now, zone);
_jobs[created.Id] = new Entry(created) { Next = next };
}
await PersistAsync(cancellationToken).ConfigureAwait(false);
Arm();
return new JobView(created, next);
}
/// <summary>Remove a job of <paramref name="target"/>. A running watcher is killed and its outcome dropped; a prompt run in progress isn't aborted. False: unknown, or another target's.</summary>
public async Task<bool> CancelAsync(string target, string id, CancellationToken cancellationToken) =>
await CancelJobAsync(target, id, cancellationToken).ConfigureAwait(false) is not null;
/// <summary><see cref="CancelAsync"/> returning the removed job (null: unknown, or another target's); looked up and removed in one step.</summary>
public async Task<ScheduledJob?> CancelJobAsync(string target, string id, CancellationToken cancellationToken)
{
Fire? running;
ScheduledJob removed;
lock (_lock)
{
if (!_jobs.TryGetValue(id, out var entry) || entry.Job.Target != target) return null;
removed = entry.Job;
_jobs.Remove(id);
_inFlight.TryGetValue(id, out running);
}
if (running is not null) await TryCancelAsync(running.Cancel).ConfigureAwait(false);
await PersistAsync(cancellationToken).ConfigureAwait(false);
Arm();
return removed;
}
/// <summary>The target's zone changed: cron jobs get their next occurrence in the new zone (strictly after now), one-offs keep their instant.</summary>
public Task<ZoneChange> TimeZoneChangedAsync(string target, CancellationToken cancellationToken)
{
var now = _time.GetUtcNow();
var zone = TimeZoneFor(target);
int cron = 0, oneOffs = 0;
lock (_lock)
{
foreach (var entry in _jobs.Values.Where(e => e.Job.Target == target))
{
if (entry.Job.Schedule.Cron is { } expression)
{
cron++;
entry.Next = Schedules.NextCron(expression, now, zone);
}
else oneOffs++;
}
}
Arm();
return Task.FromResult(new ZoneChange(cron, oneOffs));
}
/// <summary>Wait for the pending tick and every fire in flight (tests).</summary>
internal async Task IdleAsync()
{
while (true)
{
Task tick;
Task[] fires;
lock (_lock)
{
tick = _pendingTick;
fires = _inFlight.Values.Select(f => f.Task).ToArray();
}
if (tick.IsCompleted && fires.All(f => f.IsCompleted)) return;
await Task.WhenAll(fires.Append(tick)).WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
}
}
// ---- Timer and ticks ----
/// <summary>The timer callback: queues a tick behind the previous one (it may run synchronously inside <c>ITimer.Change</c>).</summary>
private void OnTimer()
{
lock (_lock)
{
if (_stopped) return;
_pendingTick = _pendingTick.ContinueWith(_ => Tick(), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default);
}
}
/// <summary>Starts every due job that isn't in flight, advances each due job's next occurrence, re-arms. Never awaits a fire.</summary>
private void Tick()
{
try
{
var now = _time.GetUtcNow();
lock (_lock)
{
if (_stopped || _delivery is null) return;
foreach (var entry in _jobs.Values)
{
if (entry.Next is not { } next || next > now) continue;
if (_inFlight.ContainsKey(entry.Job.Id))
_logger.LogInformation("Job {Id} ({Name}) is still running; skipped the occurrence due {Due}", entry.Job.Id, entry.Job.Name, next);
else StartFire(entry.Job, next, now);
// Cron: the next occurrence strictly after now. One-off: none (it's removed once its fire completes).
entry.Next = null;
if (entry.Job.Schedule.Cron is null) continue;
try
{
entry.Next = NextCron(entry.Job, now);
}
catch (Exception ex)
{
// Left unarmed rather than due forever (a zero-delay timer loop); a restart or zone change recomputes it.
_logger.LogError(ex, "Job {Id} ({Name}): computing the next occurrence failed", entry.Job.Id, entry.Job.Name);
}
}
}
}
finally
{
Arm();
}
}
/// <summary>
/// Computes the wake time and applies it to the timer under the lock, so two concurrent arms can't apply an older
/// wake after a newer one. A synchronous callback from <c>Change</c> (zero delay) is safe: <see cref="Lock"/> is
/// re-entrant and <see cref="OnTimer"/> only queues a tick.
/// </summary>
private void Arm()
{
lock (_lock)
{
if (_stopped || _timer is null) return;
var now = _time.GetUtcNow();
var soonest = _jobs.Values.Select(e => e.Next).Where(n => n is not null).DefaultIfEmpty(null).Min();
var wake = soonest is { } s && s - now < MaxTimerDelay ? s : now + MaxTimerDelay;
if (wake < now) wake = now;
_wakeAt = wake;
try
{
_timer.Change(wake - now, Timeout.InfiniteTimeSpan);
}
catch (ObjectDisposedException)
{
// Stopped meanwhile.
}
}
}
/// <summary>Under the lock. The next cron occurrence strictly after <paramref name="after"/> in the target's current zone.</summary>
private DateTimeOffset? NextCron(ScheduledJob job, DateTimeOffset after) =>
Schedules.NextCron(job.Schedule.Cron!, after, _delivery!.TimeZoneFor(job.Target));
// ---- Firing ----
/// <summary>Called under the lock: registers the fire in flight before it can complete.</summary>
private void StartFire(ScheduledJob job, DateTimeOffset dueAt, DateTimeOffset now)
{
var cancel = CancellationTokenSource.CreateLinkedTokenSource(_stopping.Token);
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var task = RunFireAsync(gate.Task, job, dueAt, now, cancel);
_inFlight[job.Id] = new Fire(task, cancel);
_firesStarted++;
gate.SetResult();
}
private async Task RunFireAsync(Task gate, ScheduledJob job, DateTimeOffset dueAt, DateTimeOffset now, CancellationTokenSource cancel)
{
await gate.ConfigureAwait(false);
try
{
await FireAsync(job, dueAt, now, cancel.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Job {Id} ({Name}) failed to fire", job.Id, job.Name);
}
finally
{
lock (_lock) _inFlight.Remove(job.Id);
cancel.Dispose();
}
}
private async Task FireAsync(ScheduledJob job, DateTimeOffset dueAt, DateTimeOffset now, CancellationToken scriptToken)
{
var delivery = _delivery!;
if (!delivery.IsDeliverable(job.Target))
{
// Kept: a cron job has already advanced; a one-off fires again after the next start.
_logger.LogWarning("Job {Id} ({Name}): target {Target} can't be served any more; skipped", job.Id, job.Name, job.Target);
return;
}
var zone = delivery.TimeZoneFor(job.Target);
DateTimeOffset? lateDue = now - dueAt > LateThreshold ? dueAt : null;
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())
: null;
break;
default:
result = await RunWatcherAsync(job, delivery, zone, scriptToken).ConfigureAwait(false);
break;
}
if (result is not null) await RecordAsync(job.Id, result).ConfigureAwait(false);
}
/// <summary>Null when the run or its delivery was cancelled (job cancelled or scheduler stopping): the outcome is dropped.</summary>
private async Task<JobResult?> RunWatcherAsync(ScheduledJob job, IJobDelivery delivery, TimeZoneInfo zone, CancellationToken scriptToken)
{
var timeout = job.TimeoutSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : _options.DefaultScriptTimeout;
var request = new ScriptRequest(job.Command!, delivery.WorkspaceFor(job.Target), timeout, job.Target, job.Id, ZoneNames.Iana(zone));
ScriptOutcome outcome;
try
{
outcome = await _runner.RunAsync(request, scriptToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (scriptToken.IsCancellationRequested)
{
return null;
}
if (scriptToken.IsCancellationRequested || !Exists(job.Id)) return null;
var at = _time.GetUtcNow();
switch (outcome)
{
case ScriptOutcome.Output output:
// The condition is met: exactly one agent prompt. Redacted: watcher stdout isn't a tool result.
return await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.WatcherMatch(job, _redactor.Redact(output.Text)), ct)).ConfigureAwait(false)
? new JobResult(true, 0, false, null, at)
: null;
case ScriptOutcome.Failed failed:
var result = new JobResult(false, failed.ExitCode, failed.TimedOut, _redactor.Redact(failed.StderrTail), at);
if (!IsRepeat(LastResult(job.Id), result)
&& !await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Failure(job, result), ct)).ConfigureAwait(false))
return null; // the alert was cut off by shutdown: not recorded, so the next failure alerts again
return result;
default:
return new JobResult(true, 0, false, null, at); // silent: nothing but LastRunAt/LastResult
}
}
/// <summary>The previous run failed the same way (exit code and timeout): the alert isn't sent again.</summary>
private static bool IsRepeat(JobResult? previous, JobResult current) =>
previous is { Ok: false } p && p.ExitCode == current.ExitCode && p.TimedOut == current.TimedOut;
/// <summary>
/// A delivery exception is logged and swallowed; the job's state still advances (no redelivery queue). False only
/// when shutdown interrupted the delivery: the fire then isn't recorded, so a one-off stays stored and fires again
/// after the next start (at-least-once).
/// </summary>
private async Task<bool> DeliverAsync(ScheduledJob job, Func<CancellationToken, Task> deliver)
{
try
{
await deliver(_stopping.Token).ConfigureAwait(false); // a prompt run isn't aborted by a cancel, only by shutdown
return true;
}
catch (OperationCanceledException) when (_stopping.IsCancellationRequested)
{
_logger.LogInformation("Job {Id} ({Name}): delivery interrupted by shutdown; kept for the next start", job.Id, job.Name);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Job {Id} ({Name}): delivery to {Target} failed", job.Id, job.Name, job.Target);
return true;
}
}
/// <summary>Update and persist the run fields; a one-off is removed now that its fire has completed.</summary>
private async Task RecordAsync(string id, JobResult result)
{
lock (_lock)
{
if (!_jobs.TryGetValue(id, out var entry)) return; // cancelled meanwhile
if (entry.Job.Schedule.At is not null) _jobs.Remove(id);
else entry.Job = entry.Job with { LastRunAt = result.At, LastResult = result };
}
try
{
await PersistAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogError(ex, "Saving the job store failed");
}
Arm();
}
/// <summary>Saves the current jobs. The snapshot is taken inside the gate, so a later save never writes older state.</summary>
private async Task PersistAsync(CancellationToken cancellationToken)
{
await _saveGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
List<ScheduledJob> snapshot;
lock (_lock) snapshot = [.. _jobs.Values.Select(e => e.Job).OrderBy(j => j.CreatedAt).ThenBy(j => j.Id, StringComparer.Ordinal)];
await _store.SaveAsync(snapshot, cancellationToken).ConfigureAwait(false);
}
finally
{
_saveGate.Release();
}
}
private static async Task TryCancelAsync(CancellationTokenSource cancel)
{
try
{
await cancel.CancelAsync().ConfigureAwait(false);
}
catch (ObjectDisposedException)
{
// The fire completed meanwhile.
}
}
private bool Exists(string id) { lock (_lock) return _jobs.ContainsKey(id); }
private JobResult? LastResult(string id) { lock (_lock) return _jobs.TryGetValue(id, out var e) ? e.Job.LastResult : null; }
/// <summary>Under the lock. Not reused while a cancelled job with that id is still in flight.</summary>
private string NewId()
{
string id;
do id = new string(Random.Shared.GetItems(IdAlphabet.AsSpan(), 5));
while (_jobs.ContainsKey(id) || _inFlight.ContainsKey(id));
return id;
}
private sealed class Entry(ScheduledJob job)
{
public ScheduledJob Job { get; set; } = job;
public DateTimeOffset? Next { get; set; }
}
private sealed record Fire(Task Task, CancellationTokenSource Cancel);
}

View File

@@ -0,0 +1,99 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Json;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>
/// <c>jobs.json</c>: <c>{ "version": 1, "jobs": [...] }</c> in <see cref="VolcanicJson"/> camelCase. Written atomically
/// (temp file + rename) through one semaphore. A corrupt file is set aside and loading starts empty, so a bad file never
/// keeps the host down; invalid jobs (including entries that don't deserialise) are dropped with a warning.
/// </summary>
public sealed class JobStore(string path, TimeProvider time, ILogger logger)
{
public const int Version = 1;
private readonly SemaphoreSlim _writeGate = new(1, 1);
public string Path => path;
public async Task<IReadOnlyList<ScheduledJob>> LoadAsync(CancellationToken cancellationToken)
{
if (!File.Exists(path)) return [];
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
StoredFile? file;
try
{
file = VolcanicJson.Deserialize<StoredFile>(json);
}
catch (JsonException)
{
file = null;
}
if (file is not { Version: Version, Jobs: not null })
{
var aside = $"{path}.corrupt-{time.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}";
File.Move(path, aside, overwrite: true);
logger.LogError("The job store {Path} could not be read; it was moved to {Aside} and the scheduler starts empty", path, aside);
return [];
}
var jobs = new List<ScheduledJob>();
var ids = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < file.Jobs.Count; i++)
{
var (job, problem) = Read(file.Jobs[i]);
problem ??= ids.Add(job!.Id) ? null : "duplicate id";
if (problem is not null)
{
logger.LogWarning("Dropped scheduled job #{Index} ({Id}) from {Path}: {Problem}", i, job?.Id, path, problem);
continue;
}
jobs.Add(job!);
}
return jobs;
}
public async Task SaveAsync(IReadOnlyList<ScheduledJob> jobs, CancellationToken cancellationToken)
{
await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
var temp = $"{path}.{Environment.ProcessId.ToString(CultureInfo.InvariantCulture)}.tmp";
try
{
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(path))!);
var json = JsonSerializer.Serialize(new JobFile(Version, [.. jobs]), VolcanicJson.Indented);
await File.WriteAllTextAsync(temp, json, cancellationToken).ConfigureAwait(false);
File.Move(temp, path, overwrite: true);
}
catch
{
try { File.Delete(temp); } catch (IOException) { } catch (UnauthorizedAccessException) { }
throw;
}
finally
{
_writeGate.Release();
}
}
/// <summary>One entry of the file: the job, or why it's dropped. An entry that doesn't deserialise only loses itself.</summary>
private static (ScheduledJob? Job, string? Problem) Read(JsonElement entry)
{
if (entry.ValueKind != JsonValueKind.Object) return (null, "not a JSON object");
ScheduledJob? job;
try
{
job = VolcanicJson.Deserialize<ScheduledJob>(entry);
}
catch (JsonException ex)
{
return (null, ex.Message);
}
if (job is null) return (null, "empty entry");
return (job, JobValidation.CheckStored(job));
}
private sealed record JobFile(int Version, List<ScheduledJob> Jobs);
private sealed record StoredFile(int Version, List<JsonElement>? Jobs);
}

View File

@@ -0,0 +1,80 @@
using System.Globalization;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>Rules shared by the <c>schedule</c> tool (new jobs) and the store loader (stored jobs).</summary>
internal static class JobValidation
{
public const int MaxNameLength = 80;
public const int MaxTextLength = 4000;
public const int MaxTimeoutSeconds = 3600;
public static readonly TimeSpan PastGrace = TimeSpan.FromSeconds(60);
/// <summary>Validates a new job and returns its schedule (<c>at</c> resolved in <paramref name="zone"/>). Throws <see cref="JobValidationException"/>.</summary>
public static JobSchedule ValidateNew(NewJob job, DateTimeOffset now, TimeZoneInfo zone)
{
var name = job.Name?.Trim() ?? "";
if (name.Length == 0) throw new JobValidationException("name is required (a short label such as \"Call the dentist\").");
if (name.Length > MaxNameLength) throw new JobValidationException($"name is {Count(name.Length)} characters; the limit is {Count(MaxNameLength)}.");
var hasAt = !string.IsNullOrWhiteSpace(job.At);
var hasCron = !string.IsNullOrWhiteSpace(job.Cron);
if (hasAt == hasCron) throw new JobValidationException("Set exactly one of at (a one-off time) or cron (a recurring schedule).");
CheckFields(job.Kind, job.Text, job.Command, job.TimeoutSeconds);
if (hasCron)
{
Schedules.ParseCron(job.Cron!);
return new JobSchedule(null, job.Cron!.Trim());
}
var at = Schedules.ParseAt(job.At!, zone);
if (at < now - PastGrace)
{
var local = TimeZoneInfo.ConvertTime(at, zone);
var localNow = TimeZoneInfo.ConvertTime(now, zone);
throw new JobValidationException(string.Create(CultureInfo.InvariantCulture,
$"at {local:yyyy-MM-dd HH:mm} ({ZoneNames.Name(zone)}) is in the past; now is {localNow:yyyy-MM-dd HH:mm}. Pick a future time."));
}
return new JobSchedule(at, null);
}
/// <summary>Null when a stored job is well formed, else why it is dropped. An overdue one-off is fine (it fires late).</summary>
public static string? CheckStored(ScheduledJob job)
{
try
{
if (string.IsNullOrWhiteSpace(job.Id) || string.IsNullOrWhiteSpace(job.Target)) return "missing id or target";
if (string.IsNullOrWhiteSpace(job.Name) || job.Name.Length > MaxNameLength) return "missing or too long name";
if (job.Schedule is null || (job.Schedule.At is null) == (job.Schedule.Cron is null)) return "needs exactly one of at and cron";
if (job.Schedule.Cron is { } cron) Schedules.ParseCron(cron);
CheckFields(job.Kind, job.Text, job.Command, job.TimeoutSeconds);
return null;
}
catch (JobValidationException ex)
{
return ex.Message;
}
}
private static void CheckFields(JobKind kind, string? text, string? command, int? timeoutSeconds)
{
var hasText = !string.IsNullOrWhiteSpace(text);
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.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.");
}
if (hasText && text!.Length > MaxTextLength) throw new JobValidationException($"text is {Count(text.Length)} characters; the limit is {Count(MaxTextLength)}.");
if (timeoutSeconds is { } seconds)
{
if (kind != JobKind.Watcher) throw new JobValidationException("timeoutSeconds is only for watchers.");
if (seconds is < 1 or > MaxTimeoutSeconds) throw new JobValidationException("timeoutSeconds must be between 1 and 3600.");
}
}
private static string Count(int n) => n.ToString(CultureInfo.InvariantCulture);
}

View File

@@ -0,0 +1,42 @@
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>Reminder: fixed text to the chat, no model call. Prompt: fixed text through the agent. Watcher: a script whose output goes to the agent.</summary>
public enum JobKind { Reminder, Prompt, Watcher }
/// <summary>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);
/// <summary>The outcome of a job's last fire. <see cref="StderrTail"/> is stored redacted.</summary>
public sealed record JobResult(bool Ok, int? ExitCode, bool TimedOut, string? StderrTail, DateTimeOffset At);
/// <summary>A persisted job (<c>jobs.json</c>).</summary>
public sealed record ScheduledJob
{
public required string Id { get; init; }
/// <summary>Who the job belongs to: the chat id (invariant culture) on Telegram.</summary>
public required string Target { get; init; }
public required string Name { get; init; }
public required JobKind Kind { get; init; }
public required JobSchedule Schedule { get; init; }
/// <summary>Reminder and prompt only.</summary>
public string? Text { get; init; }
/// <summary>Watcher only.</summary>
public string? Command { get; init; }
/// <summary>Watcher only; default <see cref="SchedulerOptions.DefaultScriptTimeout"/>, at most 3600.</summary>
public int? TimeoutSeconds { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastRunAt { get; init; }
public JobResult? LastResult { get; init; }
}
/// <summary>What the <c>schedule</c> tool asks for. <see cref="At"/> is ISO 8601 text; without an offset it's read in the target's zone.</summary>
public sealed record NewJob(string Name, JobKind Kind, string? At = null, string? Cron = null, string? Text = null, string? Command = null, int? TimeoutSeconds = null);
/// <summary>A job and its next fire time (null: never, e.g. a one-off already firing).</summary>
public sealed record JobView(ScheduledJob Job, DateTimeOffset? NextRunAt);
/// <summary>What a zone change did to one target's jobs.</summary>
public sealed record ZoneChange(int CronJobs, int OneOffs);
/// <summary>A job the model (or a stored file) got wrong. The message is a sentence the model can act on.</summary>
public sealed class JobValidationException(string message) : Exception(message);

View File

@@ -0,0 +1,99 @@
using System.ComponentModel;
using System.Text;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
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, 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, watcher: shell command run from the workspace")] string? Command = null,
[property: Description("create, watcher: timeout in seconds, 1-3600 (default 300)")] int? TimeoutSeconds = null);
/// <summary>The model's view of the scheduler, bound to one target (chat). It never sees another target's jobs.</summary>
public sealed class ScheduleTool(JobScheduler scheduler, string target) : Tool<ScheduleArgs>
{
public const string ToolName = "schedule";
public override string Name => ToolName;
public override string Description =>
"Create, list or cancel this chat's scheduled jobs: one-off or recurring reminders (fixed text), prompts (you handle them when they fire) and watcher scripts (run without you; their output comes to you only when they print something).";
public override string? PromptSnippet => "schedule: reminders, recurring prompts and watcher scripts that message this chat";
public override IReadOnlyList<string> PromptGuidelines =>
[
"schedule: times are in the chat's time zone (see the environment section); at is ISO 8601, cron has 5 fields (minute hour day-of-month month day-of-week).",
"schedule: use kind reminder for fixed text and prompt when the job needs you to think or use tools.",
"schedule: 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.",
];
protected override async Task<ToolResult> ExecuteAsync(ScheduleArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
{
try
{
return (args.Action ?? "").Trim().ToLowerInvariant() switch
{
"create" => await CreateAsync(args, cancellationToken).ConfigureAwait(false),
"list" => ToolResult.Text(List()),
"cancel" => await CancelAsync(args.Id, cancellationToken).ConfigureAwait(false),
_ => throw Error("action must be create, list or cancel."),
};
}
catch (JobValidationException ex)
{
throw Error(ex.Message);
}
catch (InvalidOperationException ex)
{
throw Error(ex.Message); // e.g. the scheduler isn't running
}
}
private async Task<ToolResult> CreateAsync(ScheduleArgs args, CancellationToken cancellationToken)
{
var kind = (args.Kind ?? "").Trim().ToLowerInvariant() switch
{
"reminder" => JobKind.Reminder,
"prompt" => JobKind.Prompt,
"watcher" => JobKind.Watcher,
_ => throw new JobValidationException("kind must be reminder, prompt or watcher."),
};
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 now = scheduler.Now;
var sb = new StringBuilder("Created: ").Append(JobFormat.Line(view, zone, now));
if (view.NextRunAt is { } next) sb.Append("\nNext run: ").Append(JobFormat.Stamp(next, zone)).Append(" (").Append(JobFormat.ZoneName(zone)).Append(')');
sb.Append('\n').Append(NowLine(zone, now));
return ToolResult.Text(sb.ToString());
}
private string List()
{
var zone = scheduler.TimeZoneFor(target);
var now = scheduler.Now;
var jobs = scheduler.List(target);
var body = jobs.Count == 0 ? "No scheduled jobs." : string.Join('\n', jobs.Select(j => JobFormat.Line(j, zone, now)));
return body + "\n" + NowLine(zone, now);
}
private async Task<ToolResult> CancelAsync(string? id, CancellationToken cancellationToken)
{
var key = id?.Trim().ToLowerInvariant() ?? "";
if (key.Length == 0) throw Error("cancel needs id (see action=list).");
var job = await scheduler.CancelJobAsync(target, key, cancellationToken).ConfigureAwait(false)
?? throw Error($"No job {key} in this chat.");
return ToolResult.Text($"Cancelled {job.Id} ({job.Name}).");
}
private static string NowLine(TimeZoneInfo zone, DateTimeOffset now) => $"Now: {JobFormat.Stamp(now, zone)} ({JobFormat.ZoneName(zone)})";
private static ToolExecutionException Error(string message) => new(ToolResult.Text(message));
}

View File

@@ -0,0 +1,22 @@
using Volcanic.AgenticCore.Abstractions.Extensions;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>
/// Per session: registers the <c>schedule</c> tool bound to the host's <paramref name="target"/> (a Telegram chat id).
/// The host adds it through <c>AgentSessionOptions.ExtraExtensions</c>. Loaded through discovery without a
/// host-registered <see cref="JobScheduler"/>, its constructor can't be resolved: an ordinary extension load error.
/// </summary>
public sealed class SchedulerExtension(JobScheduler scheduler, string target) : IExtension
{
public const string ExtensionName = "scheduler";
public string Name => ExtensionName;
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(api);
api.RegisterTool(new ScheduleTool(scheduler, target));
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,34 @@
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>The <c>Scheduler</c> configuration section. There is no zone here: zones are per target (the host's <c>IJobDelivery.TimeZoneFor</c>).</summary>
public sealed class SchedulerOptions
{
public const string SectionName = "Scheduler";
public bool Enabled { get; set; } = true;
/// <summary><c>jobs.json</c>. Empty: the host fills <see cref="DefaultStorePath"/> under its agent directory.</summary>
public string? StorePath { get; set; }
/// <summary>Watcher timeout when the job sets none.</summary>
public TimeSpan DefaultScriptTimeout { get; set; } = TimeSpan.FromSeconds(300);
public int MaxJobsPerTarget { get; set; } = 50;
/// <summary>Executable and leading arguments; the command is appended. Empty: <c>/bin/sh -c</c>, or <c>cmd.exe /d /s /c</c> on Windows (the <c>bash</c> tool's shell).</summary>
public List<string> Shell { get; set; } = [];
/// <summary><c>&lt;agentDir&gt;/scheduler/jobs.json</c>.</summary>
public static string DefaultStorePath(string agentDir) => Path.Combine(agentDir, "scheduler", "jobs.json");
/// <summary>Configuration problems that must stop the host, empty when valid.</summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (DefaultScriptTimeout < TimeSpan.FromSeconds(1) || DefaultScriptTimeout > TimeSpan.FromSeconds(JobValidation.MaxTimeoutSeconds))
errors.Add("Scheduler:DefaultScriptTimeout must be between 1 second and 1 hour.");
if (MaxJobsPerTarget < 1) errors.Add("Scheduler:MaxJobsPerTarget must be at least 1.");
if (Shell.Any(string.IsNullOrWhiteSpace)) errors.Add("Scheduler:Shell entries must not be empty.");
return errors;
}
}

View File

@@ -0,0 +1,93 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Cronos;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>Cron (Cronos, 5 fields, in a zone) and one-off times.</summary>
public static partial class Schedules
{
private const string FieldsHint = "Use 5 fields: minute hour day-of-month month day-of-week, e.g. \"0 8 * * 1-5\".";
/// <summary>Parses a standard 5-field expression or a macro (<c>@daily</c>). Seconds and <c>H</c> are rejected.</summary>
public static CronExpression ParseCron(string expression)
{
var text = expression?.Trim() ?? "";
if (text.Length == 0) throw new JobValidationException("cron is empty. " + FieldsHint);
if (!text.StartsWith('@'))
{
var fields = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (fields.Any(f => f.Split(',').Any(t => t == "H" || t.StartsWith("H(", StringComparison.Ordinal) || t.StartsWith("H/", StringComparison.Ordinal))))
throw new JobValidationException($"cron \"{text}\": the H (hash) syntax isn't supported; write the minute explicitly. {FieldsHint}");
if (fields.Length == 6)
throw new JobValidationException($"cron \"{text}\" has 6 fields; seconds aren't supported. {FieldsHint}");
if (fields.Length != 5)
throw new JobValidationException($"cron \"{text}\" has {fields.Length.ToString(CultureInfo.InvariantCulture)} fields. {FieldsHint}");
}
try
{
return CronExpression.Parse(text, CronFormat.Standard);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
throw new JobValidationException($"cron \"{text}\" is invalid: {ex.Message.TrimEnd('.')}. {FieldsHint}");
}
}
/// <summary>The next occurrence strictly after <paramref name="after"/>, as wall-clock time in <paramref name="zone"/> (Cronos DST rules).</summary>
public static DateTimeOffset? NextCron(string cron, DateTimeOffset after, TimeZoneInfo zone) =>
ParseCron(cron).GetNextOccurrence(after, zone, inclusive: false);
private static readonly string[] WallFormats = ["yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF"];
/// <summary>
/// ISO 8601 only, always with a date: <c>yyyy-MM-dd</c> (local midnight) or <c>yyyy-MM-dd[T| ]HH:mm[:ss[.fff]]</c>, with
/// an optional <c>Z</c> or offset. With an offset it's used as given; without one it's wall-clock time in
/// <paramref name="zone"/>. A time without a date (<c>09:00</c>) and culture formats (<c>09/10/2026</c>) are rejected:
/// they'd be resolved against the machine's clock, not the chat's.
/// </summary>
public static DateTimeOffset ParseAt(string text, TimeZoneInfo zone)
{
var trimmed = text?.Trim() ?? "";
var hint = $"at \"{trimmed}\" isn't a date-time. Use ISO 8601 with the date, e.g. 2026-09-27T09:00 (read in the chat's time zone) or 2026-09-27T09:00+02:00.";
var match = IsoAt().Match(trimmed);
if (!match.Success) throw new JobValidationException(hint);
var local = match.Groups["date"].Value + (match.Groups["time"].Success ? "T" + match.Groups["time"].Value : "T00:00");
if (!DateTime.TryParseExact(local, WallFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var wall))
throw new JobValidationException(hint);
wall = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
if (match.Groups["offset"] is { Success: true } group)
{
try
{
return new DateTimeOffset(wall, ParseOffset(group.Value));
}
catch (ArgumentException)
{
throw new JobValidationException(hint); // an offset beyond ±14:00
}
}
// A time skipped by a spring-forward change doesn't exist: move it past the gap (Europe/US gaps are one hour).
if (zone.IsInvalidTime(wall)) wall = wall.AddHours(1);
// An ambiguous fall-back time is read as the first of the two (the larger offset).
var offset = zone.IsAmbiguousTime(wall) ? zone.GetAmbiguousTimeOffsets(wall).Max() : zone.GetUtcOffset(wall);
return new DateTimeOffset(wall, offset);
}
/// <summary><c>Z</c>, <c>+02</c>, <c>+0200</c> or <c>+02:00</c>.</summary>
private static TimeSpan ParseOffset(string text)
{
if (text.Equals("Z", StringComparison.OrdinalIgnoreCase)) return TimeSpan.Zero;
var digits = text[1..].Replace(":", "", StringComparison.Ordinal);
var hours = int.Parse(digits[..2], NumberStyles.None, CultureInfo.InvariantCulture);
var minutes = digits.Length > 2 ? int.Parse(digits[2..], NumberStyles.None, CultureInfo.InvariantCulture) : 0;
if (minutes > 59) throw new ArgumentException("minutes", nameof(text));
var offset = new TimeSpan(hours, minutes, 0);
return text[0] == '-' ? -offset : offset;
}
/// <summary>A date, optionally a time (after <c>T</c> or a space) and then optionally <c>Z</c> or an offset.</summary>
[GeneratedRegex(@"^(?<date>\d{4}-\d{2}-\d{2})(?:[T ](?<time>\d{2}:\d{2}(?::\d{2}(?:\.\d{1,7})?)?)\s*(?<offset>Z|[+-]\d{2}(?::?\d{2})?)?)?$", RegexOptions.IgnoreCase)]
private static partial Regex IsoAt();
}

View File

@@ -0,0 +1,243 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>One watcher run. <see cref="TimeZoneId"/> becomes the script's <c>TZ</c> (the target's zone).</summary>
public sealed record ScriptRequest(string Command, string Cwd, TimeSpan Timeout, string Target, string JobId, string? TimeZoneId);
/// <summary>What a watcher run means for the job.</summary>
public abstract record ScriptOutcome
{
private ScriptOutcome() { }
/// <summary>Exit 0, empty stdout after trimming: the condition isn't met. Costs nothing.</summary>
public sealed record Silent : ScriptOutcome;
/// <summary>Exit 0 with output: the condition is met; <see cref="Text"/> (trimmed) goes to the agent.</summary>
public sealed record Output(string Text) : ScriptOutcome;
/// <summary>Non-zero exit, timeout, a hook that threw, or a shell that didn't start.</summary>
public sealed record Failed(int? ExitCode, string StderrTail, bool TimedOut) : ScriptOutcome;
}
/// <summary>Runs watcher scripts. Faked in scheduler tests.</summary>
public interface IScriptRunner
{
/// <summary>Never throws for a script problem (that's <see cref="ScriptOutcome.Failed"/>); throws <see cref="OperationCanceledException"/> after killing the script when <paramref name="cancellationToken"/> is cancelled.</summary>
Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken);
}
/// <summary>
/// Runs a watcher with the <c>bash</c> tool's shell in the target's workspace, stdin closed, with the command, cwd and
/// environment from the host's <see cref="IProcessSpawnPolicy"/> (<see cref="ProcessPurpose.ScheduledScript"/>): the same
/// filtered environment as <c>bash</c>, <c>TZ</c> = the target's zone, host hooks applied. No session-level
/// <see cref="ProcessSpawnEvent"/> runs: a job fires without a live session.
/// </summary>
public sealed class ScriptRunner(SchedulerOptions options, IProcessSpawnPolicy policy, ILogger<ScriptRunner> logger, TimeProvider? time = null) : IScriptRunner
{
public const int StderrTailLines = 20;
private static readonly TimeSpan PipeGrace = TimeSpan.FromSeconds(5);
private readonly TimeProvider _time = time ?? TimeProvider.System;
public async Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
ProcessSpawnContext spawn;
try
{
spawn = policy.Prepare(new ProcessSpawnRequest
{
Purpose = ProcessPurpose.ScheduledScript,
Command = request.Command,
Cwd = request.Cwd,
Scope = request.Target,
Origin = request.JobId,
TimeZoneId = request.TimeZoneId,
});
}
catch (Exception ex)
{
return new ScriptOutcome.Failed(null, $"process environment hook failed: {ex.Message}", false);
}
var psi = StartInfo(spawn.Command);
spawn.ApplyTo(psi); // cwd and exactly the prepared environment
using var process = new Process { StartInfo = psi };
try
{
Directory.CreateDirectory(spawn.Cwd);
process.Start();
}
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException or UnauthorizedAccessException
or ArgumentException or NotSupportedException) // e.g. a workspace path that can't be created
{
return new ScriptOutcome.Failed(null, $"failed to start {psi.FileName}: {ex.Message}", false);
}
process.StandardInput.Close();
var stdout = new OutputCapture();
var stderr = new OutputCapture();
var pumps = Task.WhenAll(PumpAsync(process.StandardOutput, stdout), PumpAsync(process.StandardError, stderr));
using var timeout = new CancellationTokenSource(request.Timeout, _time);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
var timedOut = false;
try
{
await process.WaitForExitAsync(linked.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
Kill(process, request);
timedOut = !cancellationToken.IsCancellationRequested;
}
await DrainAsync(pumps, request).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (timedOut) return new ScriptOutcome.Failed(null, Tail(stderr.ToString()), true);
if (process.ExitCode != 0) return new ScriptOutcome.Failed(process.ExitCode, Tail(stderr.ToString()), false);
var text = stdout.ToString().Trim();
return text.Length == 0 ? new ScriptOutcome.Silent() : new ScriptOutcome.Output(text);
}
/// <summary>
/// The <c>bash</c> tool's shell: <c>/bin/sh -c &lt;command&gt;</c> through <see cref="ProcessStartInfo.ArgumentList"/>. On
/// Windows <c>cmd.exe</c> doesn't parse the MSVC quoting <c>ArgumentList</c> produces, so the command goes through
/// <see cref="ProcessStartInfo.Arguments"/> as <c>/d /s /c "&lt;command&gt;"</c>, exactly as <c>bash</c> does.
/// A configured <see cref="SchedulerOptions.Shell"/> always uses <c>ArgumentList</c>.
/// </summary>
internal ProcessStartInfo StartInfo(string command)
{
var psi = new ProcessStartInfo
{
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
if (options.Shell.Count > 0)
{
psi.FileName = options.Shell[0];
foreach (var argument in options.Shell.Skip(1)) psi.ArgumentList.Add(argument);
psi.ArgumentList.Add(command);
}
else if (OperatingSystem.IsWindows())
{
psi.FileName = "cmd.exe";
psi.Arguments = $"/d /s /c \"{command}\"";
}
else
{
psi.FileName = "/bin/sh";
psi.ArgumentList.Add("-c");
psi.ArgumentList.Add(command);
}
return psi;
}
private void Kill(Process process, ScriptRequest request)
{
try
{
process.Kill(entireProcessTree: true);
process.WaitForExit(PipeGrace);
}
catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException)
{
logger.LogDebug(ex, "Job {JobId}: killing the script failed (it may have exited)", request.JobId);
}
}
/// <summary>Waits for both output pipes to close, at most <see cref="PipeGrace"/> (a surviving grandchild may hold them).</summary>
private async Task DrainAsync(Task pumps, ScriptRequest request)
{
try
{
await pumps.WaitAsync(PipeGrace, CancellationToken.None).ConfigureAwait(false);
}
catch (TimeoutException)
{
logger.LogWarning("Job {JobId}: the script's output pipes stayed open after it ended", request.JobId);
}
}
private static async Task PumpAsync(StreamReader reader, OutputCapture capture)
{
var buffer = new char[4096];
try
{
int read;
while ((read = await reader.ReadAsync(buffer.AsMemory(), CancellationToken.None).ConfigureAwait(false)) > 0)
capture.Append(buffer.AsSpan(0, read));
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
{
// The pipe broke or the process was disposed: keep what was read.
}
}
/// <summary>The last <see cref="StderrTailLines"/> lines.</summary>
internal static string Tail(string text)
{
var lines = text.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd('\n').Split('\n');
return string.Join('\n', lines.TakeLast(StderrTailLines));
}
}
/// <summary>Keeps the first <see cref="HeadChars"/> and the last <see cref="TailChars"/> characters of a stream.</summary>
internal sealed class OutputCapture
{
public const int HeadChars = 48 * 1024;
public const int TailChars = 16 * 1024;
private readonly StringBuilder _head = new();
private readonly char[] _tail = new char[TailChars];
private readonly Lock _lock = new();
private int _tailStart;
private int _tailLength;
private long _dropped;
public void Append(string text) => Append(text.AsSpan());
public void Append(ReadOnlySpan<char> text)
{
lock (_lock)
{
var toHead = Math.Min(text.Length, HeadChars - _head.Length);
if (toHead > 0) _head.Append(text[..toHead]);
foreach (var c in text[Math.Max(toHead, 0)..])
{
if (_tailLength == TailChars)
{
_tail[_tailStart] = c;
_tailStart = (_tailStart + 1) % TailChars;
_dropped++;
}
else
{
_tail[(_tailStart + _tailLength) % TailChars] = c;
_tailLength++;
}
}
}
}
public override string ToString()
{
lock (_lock)
{
var sb = new StringBuilder(_head.Length + _tailLength + 64).Append(_head);
if (_dropped > 0) sb.Append(CultureInfo.InvariantCulture, $"\n[… {_dropped} characters omitted …]\n");
for (var i = 0; i < _tailLength; i++) sb.Append(_tail[(_tailStart + i) % TailChars]);
return sb.ToString();
}
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>Scheduler extension: one-off and cron reminders, scheduled agent prompts and watcher scripts.</Description>
</PropertyGroup>
<ItemGroup>
<!-- Private="false": the host already ships Abstractions; an extension must not bring its own copy (docs/extensions.md). -->
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" Private="false" />
<PackageReference Include="Cronos" Version="0.13.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Volcanic.AgenticCore.Extensions.Tests" />
<InternalsVisibleTo Include="Volcanic.AgenticCore.Telegram.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,22 @@
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>
/// Zone ids for display and <c>TZ</c>. Mirrors the core's <c>Volcanic.AgenticCore.Hosting.TimeZoneIds</c> (the extension
/// references only the Abstractions); keep the two in step.
/// </summary>
internal static class ZoneNames
{
/// <summary>
/// The IANA id (converting a Windows id), or null when there is none that resolves back to a system zone (an unnamed
/// <c>Local</c> zone: <c>TZ=Local</c> would run the script in UTC). Same rule as <c>TimeZoneIds.ToIana</c>.
/// </summary>
public static string? Iana(TimeZoneInfo zone)
{
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
return id is not null && TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? id : null;
}
/// <summary>The IANA id when there is one, else the zone's own id (<c>TimeZoneIds.Name</c>).</summary>
public static string Name(TimeZoneInfo zone) => Iana(zone) ?? zone.Id;
}

View File

@@ -2,12 +2,15 @@ using System.Globalization;
using System.Text;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Scheduling;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>The bot's own slash commands. Anything else starting with <c>/</c> goes to the session (extension commands).</summary>
public sealed class ChatCommands(TelegramSender sender)
public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zones = null, JobScheduler? scheduler = null)
{
public static readonly IReadOnlyList<BotCommand> Menu =
[
@@ -18,6 +21,8 @@ public sealed class ChatCommands(TelegramSender sender)
new() { Command = "resume", Description = "Resume a conversation: /resume <id>" },
new() { Command = "compact", Description = "Summarise the conversation to free context" },
new() { Command = "thinking", Description = "Show or set the thinking level" },
new() { Command = "timezone", Description = "Show or set this chat's time zone" },
.. SchedulerSetup.Menu,
new() { Command = "help", Description = "What this bot does" },
];
@@ -97,6 +102,18 @@ public sealed class ChatCommands(TelegramSender sender)
await ReplyAsync(chat, await ThinkingAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
case "timezone":
await ReplyAsync(chat, await TimeZoneAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
case "jobs":
await ReplyAsync(chat, Jobs(chat), cancellationToken).ConfigureAwait(false);
return true;
case "cancel":
await ReplyAsync(chat, await CancelJobAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
default:
return false;
}
@@ -119,9 +136,10 @@ public sealed class ChatCommands(TelegramSender sender)
/sessions, /resume <id> – earlier conversations
/compact – summarise the conversation to free context
/thinking [level] – show or set the thinking level
""" + GoogleCalendarSetup.HelpLines(chat.Session.Commands);
/timezone [IANA name|reset] – this chat's time zone
""" + SchedulerSetup.HelpLines + GoogleCalendarSetup.HelpLines(chat.Session.Commands);
private static string Status(ChatContext chat)
private string Status(ChatContext chat)
{
var session = chat.Session;
var sb = new StringBuilder();
@@ -131,6 +149,10 @@ public sealed class ChatCommands(TelegramSender sender)
sb.AppendLine(CultureInfo.InvariantCulture, $"**Messages:** {session.Messages.Count}");
if (session.GetContextUsage() is { } usage)
sb.AppendLine(CultureInfo.InvariantCulture, $"**Context:** {(usage.Tokens is { } t ? t.ToString("N0", CultureInfo.InvariantCulture) : "?")} / {usage.ContextWindow.ToString("N0", CultureInfo.InvariantCulture)} tokens{(usage.Percent is { } p ? $" ({p:0}%)" : "")}");
if (scheduler is { IsStarted: true })
sb.AppendLine(CultureInfo.InvariantCulture, $"**Jobs:** {scheduler.List(TelegramJobDelivery.TargetOf(chat.ChatId)).Count}");
var zone = zones?.Describe(chat.ChatId) ?? TimeZoneIds.Name(session.TimeZone); // no service (tests): no clock read
sb.AppendLine(CultureInfo.InvariantCulture, $"**Time zone:** {zone}");
sb.AppendLine(CultureInfo.InvariantCulture, $"**Workspace:** `{session.Cwd}`");
sb.Append(session.IsStreaming ? "**State:** running" : session.IsCompacting ? "**State:** compacting" : "**State:** idle");
return sb.ToString();
@@ -163,6 +185,39 @@ public sealed class ChatCommands(TelegramSender sender)
return result.Cancelled ? "An extension cancelled the switch." : $"Resumed `{matches[0].SessionId}` ({matches[0].MessageCount} messages).";
}
private async Task<string> TimeZoneAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
{
if (zones is null) return "Time zones aren't configured on this bot.";
if (arg.Length == 0) return zones.Show(chat.ChatId);
if (arg.Equals("reset", StringComparison.OrdinalIgnoreCase)) return await zones.SetAsync(chat.ChatId, null, cancellationToken).ConfigureAwait(false);
return ChatTimeZones.TryParse(arg, out var zone, out var error)
? await zones.SetAsync(chat.ChatId, zone, cancellationToken).ConfigureAwait(false)
: error;
}
private string Jobs(ChatContext chat)
{
if (scheduler is not { IsStarted: true }) return SchedulerSetup.DisabledReply;
var target = TelegramJobDelivery.TargetOf(chat.ChatId);
var jobs = scheduler.List(target);
if (jobs.Count == 0) return "No scheduled jobs.";
var zone = scheduler.TimeZoneFor(target);
var now = scheduler.Now;
return $"**Scheduled jobs** ({JobFormat.ZoneName(zone)}, now {JobFormat.DayTime(now, zone)})\n" +
string.Join('\n', jobs.Select(j => JobFormat.Line(j, zone, now)));
}
private async Task<string> CancelJobAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
{
if (scheduler is not { IsStarted: true }) return SchedulerSetup.DisabledReply;
var id = arg.Trim().ToLowerInvariant();
if (id.Length == 0) return "Usage: /cancel <id> (see /jobs).";
var target = TelegramJobDelivery.TargetOf(chat.ChatId);
return await scheduler.CancelJobAsync(target, id, cancellationToken).ConfigureAwait(false) is { } job
? $"Cancelled {job.Id} ({job.Name})."
: $"No job {id} in this chat.";
}
private static async Task<string> ThinkingAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
{
var session = chat.Session;

View File

@@ -5,7 +5,9 @@ using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Rendering;
@@ -69,7 +71,10 @@ public sealed class ChatRegistry(
TelegramOptions options,
ILoggerFactory loggerFactory,
TimeProvider? time = null,
GoogleCalendarOptions? calendar = null) : IAsyncDisposable
GoogleCalendarOptions? calendar = null,
ChatSettingsStore? settings = null,
ChatTimeZoneService? zones = null,
JobScheduler? scheduler = null) : IAsyncDisposable
{
private readonly ConcurrentDictionary<long, Lazy<Task<ChatContext>>> _chats = new();
private readonly ILogger _logger = loggerFactory.CreateLogger<ChatRegistry>();
@@ -108,6 +113,11 @@ public sealed class ChatRegistry(
var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger<ChatRenderer>());
var ui = new TelegramUi(chatId, sender, renderer);
var channel = new TelegramChatChannel(chatId, sender, renderer);
var extras = new List<ExtensionRegistration>();
if (calendar is not null) extras.Add(GoogleCalendarSetup.Registration(calendar));
if (zones is not null) extras.Add(ExtensionRegistration.FromInstance(new ChatSettingsExtension(chatId, zones)));
if (scheduler is not null)
extras.Add(ExtensionRegistration.FromInstance(new SchedulerExtension(scheduler, Scheduling.TelegramJobDelivery.TargetOf(chatId))));
AgentSession session;
try
{
@@ -119,7 +129,8 @@ public sealed class ChatRegistry(
Model = options.Model,
Ui = ui,
Channel = channel,
ExtraExtensions = calendar is null ? null : [GoogleCalendarSetup.Registration(calendar)],
TimeZone = settings?.TimeZoneFor(chatId),
ExtraExtensions = extras,
}).ConfigureAwait(false);
}
catch
@@ -139,11 +150,11 @@ public sealed class ChatRegistry(
}
/// <summary>
/// The zone for a chat's timestamp headers: the chat session's zone, else the machine's. This is the one place that
/// switches to the per-chat setting (<c>ChatSettingsStore.TimeZoneFor</c>, scheduler spec) when that lands.
/// Read at flush time, so a zone change applies to the next batch.
/// The zone for a chat's timestamp headers: the chat's setting (<see cref="ChatSettingsStore.TimeZoneFor"/>), which also
/// feeds the session's zone. Without a store (bare test setups) the session's zone, else the machine's. Read at flush
/// time, so a <c>/timezone</c> change applies to the next batch. Never creates a session.
/// </summary>
public TimeZoneInfo TimeZoneFor(long chatId) => Find(chatId)?.Session.TimeZone ?? TimeZoneInfo.Local;
public TimeZoneInfo TimeZoneFor(long chatId) => settings?.TimeZoneFor(chatId) ?? Find(chatId)?.Session.TimeZone ?? TimeZoneInfo.Local;
/// <summary>A flushed batch becomes one prompt; while a run is active it steers it and each message gets a 👀 reaction.</summary>
internal Task FlushAsync(ChatContext chat, IReadOnlyList<InboundPart> parts)

View File

@@ -0,0 +1,34 @@
using System.ComponentModel;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>Arguments of <c>set_timezone</c>.</summary>
public sealed record SetTimeZoneArgs([property: Description("IANA time zone, e.g. Europe/Berlin or America/New_York")] string Zone);
/// <summary>Host extension per chat: the <c>set_timezone</c> tool, whether or not the scheduler is enabled.</summary>
public sealed class ChatSettingsExtension(long chatId, ChatTimeZoneService zones) : IExtension
{
public const string ExtensionName = "telegram-chat-settings";
public const string ToolName = "set_timezone";
public string Name => ExtensionName;
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(api);
api.RegisterTool(new DelegateTool<SetTimeZoneArgs>(
ToolName,
"Set this chat's time zone (an IANA name). It applies to the date in your instructions, bash, scheduled jobs and message timestamps.",
async (args, _, ct) =>
{
if (!ChatTimeZones.TryParse(args.Zone, out var zone, out var error)) throw new ToolExecutionException(ToolResult.Text(error));
return ToolResult.Text(await zones.SetAsync(chatId, zone, ct).ConfigureAwait(false));
},
promptSnippet: "set_timezone: change this chat's time zone",
promptGuidelines: ["Use set_timezone only when the user tells you their location or time zone changed; confirm the new zone in your reply."],
executionMode: ToolExecutionMode.Sequential));
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,106 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// Per-chat settings outside every workspace: <c>&lt;AgentDir&gt;/telegram/chat-settings.json</c>,
/// <c>{ "version": 1, "chats": { "&lt;chatId&gt;": { "timeZone": "Asia/Tokyo" } } }</c>. Loaded once and cached; written
/// atomically (temp file + rename) through one semaphore. A corrupt file is set aside and loading starts empty; a stored
/// zone that no longer resolves is logged and ignored (the chat follows the default). Exists with the scheduler disabled.
/// </summary>
public sealed class ChatSettingsStore
{
public const int Version = 1;
private readonly string _path;
private readonly ILogger _logger;
private readonly SemaphoreSlim _writeGate = new(1, 1);
private readonly Lock _lock = new();
private readonly Dictionary<long, TimeZoneInfo> _zones = [];
public ChatSettingsStore(string path, TimeZoneInfo defaultZone, ILogger<ChatSettingsStore> logger, TimeProvider? time = null)
{
ArgumentNullException.ThrowIfNull(defaultZone);
_path = path;
_logger = logger;
DefaultZone = defaultZone;
Load(time ?? TimeProvider.System);
}
public static string DefaultPath(string agentDir) => Path.Combine(agentDir, "telegram", "chat-settings.json");
/// <summary><c>Telegram:DefaultTimeZone</c>, else the machine's zone. Chats without their own zone follow it.</summary>
public TimeZoneInfo DefaultZone { get; }
public TimeZoneInfo TimeZoneFor(long chatId)
{
lock (_lock) return _zones.GetValueOrDefault(chatId) ?? DefaultZone;
}
public bool HasOwnZone(long chatId)
{
lock (_lock) return _zones.ContainsKey(chatId);
}
/// <summary>Stores <paramref name="ianaId"/> for the chat; null removes the entry (back to the default).</summary>
public async Task SetTimeZoneAsync(long chatId, string? ianaId, CancellationToken cancellationToken)
{
var zone = ianaId is null ? null : TimeZoneInfo.FindSystemTimeZoneById(ianaId); // throws before anything changes
await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Under the write gate, so the snapshot written last is also the newest one.
SettingsFile snapshot;
lock (_lock)
{
if (zone is null) _zones.Remove(chatId);
else _zones[chatId] = zone;
snapshot = new SettingsFile(Version, _zones.ToDictionary(
kv => kv.Key.ToString(CultureInfo.InvariantCulture),
kv => new ChatSettings(TimeZoneIds.Name(kv.Value))));
}
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
var temp = $"{_path}.{Environment.ProcessId.ToString(CultureInfo.InvariantCulture)}.tmp";
await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(snapshot, VolcanicJson.Indented), CancellationToken.None).ConfigureAwait(false);
File.Move(temp, _path, overwrite: true);
}
finally
{
_writeGate.Release();
}
}
private void Load(TimeProvider time)
{
if (!File.Exists(_path)) return;
SettingsFile? file;
try
{
file = VolcanicJson.Deserialize<SettingsFile>(File.ReadAllText(_path));
}
catch (JsonException)
{
file = null;
}
if (file is not { Version: Version, Chats: not null })
{
var aside = $"{_path}.corrupt-{time.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}";
File.Move(_path, aside, overwrite: true);
_logger.LogError("Chat settings {Path} could not be read; moved to {Aside}, every chat follows the default zone", _path, aside);
return;
}
foreach (var (key, settings) in file.Chats)
{
if (!long.TryParse(key, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var chatId) || settings?.TimeZone is not { } id) continue;
if (TimeZoneInfo.TryFindSystemTimeZoneById(id, out var zone)) _zones[chatId] = zone;
else _logger.LogWarning("Chat {ChatId}: stored time zone {Zone} is unknown here; the chat follows the default", chatId, id);
}
}
private sealed record SettingsFile(int Version, Dictionary<string, ChatSettings>? Chats);
private sealed record ChatSettings(string? TimeZone);
}

View File

@@ -0,0 +1,66 @@
using System.Globalization;
using System.Text;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Scheduling;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// The one path for a chat's zone change (<c>/timezone</c> and <c>set_timezone</c>): store it, apply it to the live
/// session (from its next run), re-evaluate the chat's cron jobs, and word the reply. <paramref name="liveSession"/>
/// finds the chat's session without creating one; it's a function so DI has no ChatRegistry ↔ service cycle.
/// </summary>
public sealed class ChatTimeZoneService(ChatSettingsStore store, Func<long, AgentSession?> liveSession, TimeProvider time, JobScheduler? scheduler = null)
{
/// <summary>Set (<paramref name="zone"/>) or reset (null) the chat's zone and return the confirmation.</summary>
public async Task<string> SetAsync(long chatId, TimeZoneInfo? zone, CancellationToken cancellationToken)
{
await store.SetTimeZoneAsync(chatId, zone is null ? null : TimeZoneIds.Name(zone), cancellationToken).ConfigureAwait(false);
var effective = store.TimeZoneFor(chatId);
liveSession(chatId)?.SetTimeZone(effective);
var now = time.GetUtcNow();
var text = new StringBuilder(zone is null
? $"Time zone reset to the default, {Describe(effective, now)}."
: $"Time zone set to {Describe(effective, now)}.");
text.Append(" Now ").Append(JobFormat.DayTime(now, effective)).Append('.');
if (scheduler is { IsStarted: true })
{
var target = TelegramJobDelivery.TargetOf(chatId);
var change = await scheduler.TimeZoneChangedAsync(target, cancellationToken).ConfigureAwait(false);
text.Append(JobClause(change, scheduler.List(target), effective));
}
return text.ToString();
}
/// <summary><c>Time zone: Europe/Berlin (UTC+02:00, default). Change it with /timezone &lt;IANA name&gt;, e.g. /timezone America/New_York.</c></summary>
public string Show(long chatId)
{
var zone = store.TimeZoneFor(chatId);
var marker = store.HasOwnZone(chatId) ? "" : ", default";
return $"Time zone: {TimeZoneIds.Name(zone)} ({TimeZoneIds.FormatOffset(zone.GetUtcOffset(time.GetUtcNow()))}{marker}). Change it with /timezone <IANA name>, e.g. /timezone America/New_York.";
}
/// <summary><c>Asia/Tokyo (UTC+09:00)</c>, the chat's zone now (for <c>/status</c>).</summary>
public string Describe(long chatId) => Describe(store.TimeZoneFor(chatId), time.GetUtcNow());
/// <summary><c>Asia/Tokyo (UTC+09:00)</c>.</summary>
public static string Describe(TimeZoneInfo zone, DateTimeOffset at) => $"{TimeZoneIds.Name(zone)} ({TimeZoneIds.FormatOffset(zone.GetUtcOffset(at))})";
/// <summary><c> 2 recurring jobs now follow this zone; 1 one-off job keeps its time (now 27 Sep 16:00 here).</c> Empty without jobs.</summary>
private static string JobClause(ZoneChange change, IReadOnlyList<JobView> jobs, TimeZoneInfo zone)
{
var parts = new List<string>();
if (change.CronJobs > 0)
parts.Add(change.CronJobs == 1 ? "1 recurring job now follows this zone" : $"{change.CronJobs.ToString(CultureInfo.InvariantCulture)} recurring jobs now follow this zone");
if (change.OneOffs > 0)
{
var times = string.Join(", ", jobs.Where(v => v.Job.Schedule.At is not null).Select(v => JobFormat.DayTime(v.Job.Schedule.At!.Value, zone)));
parts.Add(change.OneOffs == 1
? $"1 one-off job keeps its time (now {times} here)"
: $"{change.OneOffs.ToString(CultureInfo.InvariantCulture)} one-off jobs keep their times (now {times} here)");
}
return parts.Count == 0 ? "" : " " + string.Join("; ", parts) + ".";
}
}

View File

@@ -0,0 +1,25 @@
using System.Text.RegularExpressions;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>Parses what a user (<c>/timezone</c>) or the agent (<c>set_timezone</c>) calls a zone. The stored form is the IANA id.</summary>
public static partial class ChatTimeZones
{
/// <summary>Accepts IANA ids and Windows ids (converted); rejects <c>Local</c> and bare offsets such as <c>+03:00</c> or <c>GMT+3</c>.</summary>
public static bool TryParse(string? input, out TimeZoneInfo zone, out string error)
{
var text = input?.Trim() ?? "";
zone = TimeZoneInfo.Utc;
error = $"Unknown time zone \"{text}\". Use an IANA name such as Europe/Berlin or America/New_York.";
if (text.Length == 0 || text.Equals("Local", StringComparison.OrdinalIgnoreCase) || BareOffset().IsMatch(text)) return false;
if (!TimeZoneInfo.TryFindSystemTimeZoneById(text, out var found)) return false;
if (TimeZoneIds.ToIana(found) is not { } iana || !TimeZoneInfo.TryFindSystemTimeZoneById(iana, out var resolved)) return false;
zone = resolved;
error = "";
return true;
}
[GeneratedRegex(@"^(utc|gmt)?\s*[+-]\s*\d", RegexOptions.IgnoreCase)]
private static partial Regex BareOffset();
}

View File

@@ -7,9 +7,11 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Telegram.Bot;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Features.Processes;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram;
@@ -17,6 +19,7 @@ using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Polling;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Scheduling;
using Volcanic.AgenticCore.Telegram.Speech;
var builder = Host.CreateApplicationBuilder(args);
@@ -31,7 +34,10 @@ var processEnvironment = new ProcessEnvironmentOptions();
TelegramProcessEnvironment.Configure(processEnvironment, options, processSection, Environment.GetEnvironmentVariable, calendar);
var mcp = builder.Configuration.GetSection(McpOptions.SectionName).Get<McpOptions>() ?? new McpOptions();
mcp.ApplyEnvironment(Environment.GetEnvironmentVariable);
var errors = options.Validate().Concat(processEnvironment.Validate()).Concat(mcp.Validate()).ToList();
// One agent directory for sessions, chat-settings.json and scheduler/jobs.json.
var agentDir = VolcanicPaths.DefaultAgentDir;
var scheduling = SchedulerSetup.Options(builder.Configuration.GetSection(SchedulerOptions.SectionName), agentDir);
var errors = options.Validate().Concat(processEnvironment.Validate()).Concat(mcp.Validate()).Concat(scheduling.Validate()).ToList();
if (errors.Count > 0)
{
foreach (var error in errors) Console.Error.WriteLine(error);
@@ -40,6 +46,7 @@ if (errors.Count > 0)
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(mcp);
builder.Services.AddSingleton(scheduling);
builder.Services.AddSingleton(options.Speech);
if (options.Speech.IsEnabled) // no key, no ISpeechToText: voice notes get the setup notice
// InboundMedia (a singleton) keeps this typed client for the process lifetime, so the factory never rotates its
@@ -58,8 +65,10 @@ builder.Services.AddSingleton(sp => new ClaudeCodeProviderOptions
});
builder.Services.AddAgenticCore(o =>
{
o.AgentDir = agentDir;
o.DefaultModel = options.Model;
o.ProcessEnvironment = processEnvironment;
o.DefaultTimeZone = options.ConfiguredTimeZone();
o.AddExtension<ClaudeCodeProviderExtension>();
o.AddExtension<McpExtension>();
});
@@ -68,12 +77,24 @@ builder.Services.AddSingleton(new ReconnectPolicy(options.ReconnectInitialDelay,
builder.Services.AddSingleton<TelegramSender>();
builder.Services.TryAddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ChatRegistry>();
builder.Services.AddSingleton(sp => new ChatSettingsStore(
ChatSettingsStore.DefaultPath(agentDir),
options.ConfiguredTimeZone() ?? TimeZoneInfo.Local,
sp.GetRequiredService<ILogger<ChatSettingsStore>>(),
sp.GetRequiredService<TimeProvider>()));
// Finds a chat's live session lazily (at call time), so ChatRegistry <-> ChatTimeZoneService is no construction cycle.
builder.Services.AddSingleton(sp => new ChatTimeZoneService(
sp.GetRequiredService<ChatSettingsStore>(),
chatId => sp.GetRequiredService<ChatRegistry>().Find(chatId)?.Session,
sp.GetRequiredService<TimeProvider>(),
sp.GetService<JobScheduler>()));
builder.Services.AddSingleton<ChatCommands>();
builder.Services.AddSingleton<InboundMedia>();
builder.Services.AddSingleton<InboundComposer>();
builder.Services.AddSingleton<IUpdateRouter, UpdateRouter>();
builder.Services.AddSingleton<PollingService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<PollingService>());
if (scheduling.Enabled) SchedulerSetup.Register(builder.Services); // the hosted service starts after PollingService
builder.Services.AddHostedService<BotSetupService>();
using var host = builder.Build();
@@ -88,6 +109,8 @@ if (ClaudeInstallation.Locate(Environment.GetEnvironmentVariable) is { } claude)
logger.LogInformation("Using Claude Code at {Path}, model {Model}", claude, options.Model);
else
logger.LogWarning("Claude Code (claude) was not found on PATH; requests will fail until it is installed and logged in. Send /claude-code-doctor to the bot for details.");
logger.LogInformation("Scheduler: {State}", scheduling.Enabled ? scheduling.StorePath : "off");
logger.LogInformation("Default time zone: {Zone}", TimeZoneIds.Name(options.ConfiguredTimeZone() ?? TimeZoneInfo.Local));
logger.LogInformation("Allowed users: {Users}. Workspaces under {Root}", string.Join(", ", options.AllowedUserIds), options.WorkspaceRoot);
var polling = host.Services.GetRequiredService<PollingService>();

View File

@@ -0,0 +1,17 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Extensions.Scheduler;
namespace Volcanic.AgenticCore.Telegram.Scheduling;
/// <summary>Starts the scheduler with the Telegram delivery (registered after <c>PollingService</c>) and stops it on shutdown.</summary>
public sealed class SchedulerHostedService(JobScheduler scheduler, TelegramJobDelivery delivery, ILogger<SchedulerHostedService> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
await scheduler.StartAsync(delivery, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Scheduler started; jobs in {Path}", scheduler.StorePath);
}
public Task StopAsync(CancellationToken cancellationToken) => scheduler.StopAsync(cancellationToken);
}

View File

@@ -0,0 +1,39 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Extensions.Scheduler;
namespace Volcanic.AgenticCore.Telegram.Scheduling;
/// <summary>Everything the Telegram host does for the scheduler, kept out of the shared host files.</summary>
public static class SchedulerSetup
{
public const string DisabledReply = "Scheduling is turned off on this bot (Scheduler:Enabled).";
public const string HelpLines = "\n/jobs – this chat's scheduled reminders, prompts and watchers\n/cancel <id> – cancel one of them";
public static readonly IReadOnlyList<BotCommand> Menu =
[
new() { Command = "jobs", Description = "Scheduled reminders, prompts and watchers" },
new() { Command = "cancel", Description = "Cancel a scheduled job: /cancel <id>" },
];
/// <summary>The <c>Scheduler</c> section; an empty <c>StorePath</c> becomes <c>&lt;agentDir&gt;/scheduler/jobs.json</c>.</summary>
public static SchedulerOptions Options(IConfiguration section, string agentDir)
{
var options = section.Get<SchedulerOptions>() ?? new SchedulerOptions();
if (string.IsNullOrWhiteSpace(options.StorePath)) options.StorePath = SchedulerOptions.DefaultStorePath(agentDir);
return options;
}
/// <summary>Runner, scheduler, delivery and the hosted service (register after <c>PollingService</c>). Only when enabled.</summary>
public static void Register(IServiceCollection services)
{
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton<IScriptRunner, ScriptRunner>();
services.AddSingleton<JobScheduler>();
services.AddSingleton<TelegramJobDelivery>();
services.AddHostedService<SchedulerHostedService>();
}
}

View File

@@ -0,0 +1,65 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Inbound;
namespace Volcanic.AgenticCore.Telegram.Scheduling;
/// <summary>
/// The scheduler's view of Telegram. Targets are chat ids (invariant culture). Direct texts go through the chat's
/// <c>IChatChannel</c> (renderer queue, splitting, HTML with fallback, retries); prompts go straight to the chat's session
/// as follow-ups, never through <c>UpdateRouter</c> or the <c>ChatInbox</c>, so they are never debounced into a user's
/// message and never answer a pending question. <see cref="WorkspaceFor"/> and <see cref="TimeZoneFor"/> never create or
/// look up a session: a silent watcher tick leaves the chat untouched.
/// </summary>
public sealed class TelegramJobDelivery(ChatRegistry chats, ChatSettingsStore settings, TelegramOptions options, TimeProvider time, ILogger<TelegramJobDelivery> logger) : IJobDelivery
{
public static long ChatIdOf(string target) => long.Parse(target, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture);
public static string TargetOf(long chatId) => chatId.ToString(CultureInfo.InvariantCulture);
private static bool TryChatIdOf(string target, out long chatId) =>
long.TryParse(target, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out chatId);
public async Task SendTextAsync(string target, string markdown, CancellationToken cancellationToken)
{
var chat = await chats.GetAsync(ChatIdOf(target), cancellationToken).ConfigureAwait(false);
await chat.Channel.SendTextAsync(markdown, cancellationToken).ConfigureAwait(false);
}
public async Task RunPromptAsync(string target, string prompt, CancellationToken cancellationToken)
{
var chatId = ChatIdOf(target);
var chat = await chats.GetAsync(chatId, cancellationToken).ConfigureAwait(false);
var text = MessageContext.TimestampHeader(time.GetUtcNow(), TimeZoneFor(target)) + "\n" + prompt;
try
{
// Idle: starts (or joins, as a follow-up) a run and returns when it settles. Busy: queued as a follow-up (never
// steering) and returns at once; the job then stays in flight until the run that consumed it settles.
var queued = chat.Session.IsStreaming;
await chat.Session.PromptAsync(text, null, StreamingBehavior.FollowUp, cancellationToken).ConfigureAwait(false);
if (queued) await chat.Session.WaitForIdleAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
logger.LogError(ex, "Scheduled prompt in chat {ChatId} failed", chatId);
chat.Report($"The scheduled job failed: {ex.Message}");
}
}
public string WorkspaceFor(string target) => chats.WorkspaceFor(ChatIdOf(target));
/// <summary>Private chat id = user id: the chat is served while its user is allow-listed.</summary>
public bool IsDeliverable(string target) =>
TryChatIdOf(target, out var chatId) && options.IsAllowed(chatId);
/// <summary>An unparsable target (a hand-edited store) gets the default zone rather than an exception.</summary>
public TimeZoneInfo TimeZoneFor(string target) =>
TryChatIdOf(target, out var chatId) ? settings.TimeZoneFor(chatId) : settings.DefaultZone;
}

View File

@@ -44,6 +44,13 @@ public sealed class TelegramOptions
/// <summary>Speech to text for voice notes and audio (<c>Telegram:Speech</c>).</summary>
public SpeechOptions Speech { get; set; } = new();
/// <summary>IANA zone for chats that haven't set one with <c>/timezone</c>; empty = the machine's zone. Also <c>DEFAULT_TIMEZONE</c>.</summary>
public string? DefaultTimeZone { get; set; }
/// <summary>The configured default zone, or null when unset (then <see cref="TimeZoneInfo.Local"/> applies).</summary>
public TimeZoneInfo? ConfiguredTimeZone() =>
!string.IsNullOrWhiteSpace(DefaultTimeZone) && Chats.ChatTimeZones.TryParse(DefaultTimeZone, out var zone, out _) ? zone : null;
/// <summary>Reads the environment-variable shortcuts the README documents, when the section leaves them empty.</summary>
public void ApplyEnvironment(Func<string, string?> getEnv)
{
@@ -60,6 +67,8 @@ public sealed class TelegramOptions
Speech.ApiKey = key.Trim();
if (string.IsNullOrWhiteSpace(Speech.Model) && getEnv("STT_MODEL") is { } model && !string.IsNullOrWhiteSpace(model))
Speech.Model = model.Trim();
if (string.IsNullOrWhiteSpace(DefaultTimeZone) && getEnv("DEFAULT_TIMEZONE") is { } tz && !string.IsNullOrWhiteSpace(tz))
DefaultTimeZone = tz.Trim();
}
/// <summary>Configuration problems that must stop the host, empty when valid.</summary>
@@ -81,6 +90,8 @@ public sealed class TelegramOptions
// On Linux "/v1" parses as an absolute file:// URI; the scheme check rejects it.
if (!Uri.TryCreate(Speech?.BaseUrl, UriKind.Absolute, out var sttUri) || sttUri.Scheme is not ("http" or "https"))
errors.Add("Telegram:Speech:BaseUrl must be an absolute http(s) URL.");
if (!string.IsNullOrWhiteSpace(DefaultTimeZone) && !Chats.ChatTimeZones.TryParse(DefaultTimeZone, out _, out var zoneError))
errors.Add("Telegram:DefaultTimeZone: " + zoneError);
return errors;
}

View File

@@ -8,6 +8,7 @@
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Telegram.Bot" Version="22.10.3.1" />

View File

@@ -22,8 +22,7 @@ public sealed partial class AgentSession : IExtensionHost
Model? IExtensionHost.Model => _model;
ThinkingLevel IExtensionHost.ThinkingLevel => _thinkingLevel;
TimeZoneInfo IExtensionHost.TimeZone => TimeZone;
string? IExtensionHost.TimeZoneId =>
(_options.TimeZone ?? _coreOptions.DefaultTimeZone) is { } zone ? TimeZoneIds.ToIana(zone) : null;
string? IExtensionHost.TimeZoneId => ConfiguredTimeZone is { } zone ? TimeZoneIds.ToIana(zone) : null;
CancellationToken IExtensionHost.Signal
{
get { lock (_stateLock) return _abortCts?.Token ?? CancellationToken.None; }

View File

@@ -12,18 +12,31 @@ namespace Volcanic.AgenticCore.Features.Agent;
/// <summary>The agent loop: turns, streaming with retry and overflow recovery, queues, compaction between turns.</summary>
public sealed partial class AgentSession
{
/// <summary>Starts a run if none is active. When a run is already active the messages must have been queued by the caller.</summary>
private async Task StartRunAsync(UserMessage? prompt, IReadOnlyList<ImageContent>? images, CancellationToken cancellationToken)
/// <summary>
/// Starts a run if none is active. When a run started meanwhile (after the caller's <see cref="IsStreaming"/> check),
/// <paramref name="prompt"/> joins it: as a follow-up when <paramref name="behavior"/> is
/// <see cref="StreamingBehavior.FollowUp"/>, else as steering. Either way the call returns when that run settles.
/// </summary>
internal async Task StartRunAsync(UserMessage? prompt, IReadOnlyList<ImageContent>? images, CancellationToken cancellationToken, StreamingBehavior? behavior = null)
{
await _runGate.WaitAsync(cancellationToken).ConfigureAwait(false);
Task run;
var queuedFollowUp = false;
try
{
lock (_stateLock)
{
if (_runTask is { IsCompleted: false })
{
if (prompt is not null) _steering.Enqueue(prompt);
if (prompt is not null)
{
if (behavior == StreamingBehavior.FollowUp)
{
_followUp.Enqueue(prompt);
queuedFollowUp = true;
}
else _steering.Enqueue(prompt);
}
run = _runTask;
}
else
@@ -34,6 +47,7 @@ public sealed partial class AgentSession
}
}
finally { _runGate.Release(); }
if (queuedFollowUp) Emit(new AgentSessionEvent.QueueUpdate(_steering.Texts(), _followUp.Texts()));
await run.ConfigureAwait(false);
}

View File

@@ -67,6 +67,7 @@ public sealed partial class AgentSession : IAsyncDisposable
private bool _isCompacting;
private CompactOptions? _pendingCompaction;
private string? _systemPromptForRun;
private volatile TimeZoneInfo? _timeZoneOverride;
private bool _disposed;
internal AgentSession(
@@ -100,8 +101,22 @@ public sealed partial class AgentSession : IAsyncDisposable
public string SessionId => _session.SessionId;
public string Cwd => _options.Cwd;
/// <summary>The session's time zone: the session option, else the host default, else the machine's zone.</summary>
public TimeZoneInfo TimeZone => _options.TimeZone ?? _coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local;
/// <summary>The session's time zone: <see cref="SetTimeZone"/>, else the session option, else the host default, else the machine's zone.</summary>
public TimeZoneInfo TimeZone => ConfiguredTimeZone ?? TimeZoneInfo.Local;
/// <summary>The zone set for this session (override, option, host default), or null when none is: then children inherit the host's <c>TZ</c>.</summary>
private TimeZoneInfo? ConfiguredTimeZone => _timeZoneOverride ?? _options.TimeZone ?? _coreOptions.DefaultTimeZone;
/// <summary>
/// Change the session's zone (a host's per-chat setting). Tools and process spawns see it at once; the system prompt
/// is rendered per run, so a run in progress keeps its prompt and the next run shows the new date and zone. Not
/// persisted in session entries: the host owns that setting and passes <see cref="AgentSessionOptions.TimeZone"/>.
/// </summary>
public void SetTimeZone(TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(zone);
_timeZoneOverride = zone;
}
/// <summary>The host's chat channel for this session (e.g. for scheduled deliveries), or null.</summary>
public IChatChannel? Channel => _options.Channel;
public Model? Model => _model;
@@ -183,7 +198,8 @@ public sealed partial class AgentSession : IAsyncDisposable
}
}
await StartRunAsync(message, images, cancellationToken).ConfigureAwait(false);
// A run may start between the check above and the run gate; behavior then decides how the prompt joins it.
await StartRunAsync(message, images, cancellationToken, behavior).ConfigureAwait(false);
}
/// <summary>Queue a steering message; delivered after the current turn's tools, before the next model call.</summary>

View File

@@ -34,10 +34,12 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
private readonly VolcanicPaths _paths;
private readonly ISecretRedactor _redactor;
private readonly IProcessSpawnPolicy _processes;
private readonly TimeProvider _time;
public AgentSessionFactory(IServiceProvider services, IOptions<AgenticCoreOptions> options, ILoggerFactory loggerFactory,
ISecretRedactor redactor, IProcessSpawnPolicy processes)
ISecretRedactor redactor, IProcessSpawnPolicy processes, TimeProvider? time = null)
{
_time = time ?? TimeProvider.System;
_processes = processes;
_services = services;
_options = options.Value;
@@ -101,7 +103,12 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
}
if (options.ActiveTools is not null) tools.SetActive(options.ActiveTools);
var promptBuilder = new SystemPromptBuilder(cwd, () => tools.ActiveTools);
// The builder is created before the session; the zone is read from the session at every render (SetTimeZone).
AgentSession? owner = null;
// The prompt is only rendered by a run, after owner is assigned; the fallback covers an extension rendering it
// during activation, and follows the session's own order (AgentSession.TimeZone).
var promptBuilder = new SystemPromptBuilder(cwd, () => tools.ActiveTools, _time,
() => owner?.TimeZone ?? options.TimeZone ?? _options.DefaultTimeZone ?? TimeZoneInfo.Local);
var compactor = new Compactor(_options.Compaction, models);
Model? model = null;
@@ -114,6 +121,7 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
var thinking = options.ThinkingLevel ?? _options.DefaultThinkingLevel;
var agentSession = new AgentSession(session, store, runner, tools, commands, models, compactor, promptBuilder, options, _options, model, thinking, logger, _redactor);
owner = agentSession;
runner.Bind(agentSession);
if (resumed) agentSession.RestoreBranchSettings();
else if (model is not null) await session.AppendModelChangeAsync(model, cancellationToken).ConfigureAwait(false);

View File

@@ -1,13 +1,15 @@
using System.Globalization;
using System.Text;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Features.SystemPrompt;
/// <summary>
/// Composes the system prompt: base prompt, tools section (from <see cref="ITool.PromptSnippet"/>), guidelines,
/// extra sections, project context files (AGENTS.md, else CLAUDE.md, per directory walking up from cwd), the agent's
/// persona (PERSONA.md in cwd), its memory (MEMORY.md in cwd), then date and cwd. File names match case-insensitively.
/// persona (PERSONA.md in cwd), its memory (MEMORY.md in cwd), then date, time zone and cwd. File names match case-insensitively.
/// </summary>
public sealed class SystemPromptBuilder
{
@@ -15,13 +17,19 @@ public sealed class SystemPromptBuilder
private readonly string _cwd;
private readonly Func<IReadOnlyList<ITool>> _activeTools;
private readonly Func<DateTimeOffset> _now;
private readonly TimeProvider _time;
private readonly Func<TimeZoneInfo> _zone;
public SystemPromptBuilder(string cwd, Func<IReadOnlyList<ITool>> activeTools, Func<DateTimeOffset>? now = null)
/// <param name="cwd">The session's working directory.</param>
/// <param name="activeTools">The session's active tools, read at every render.</param>
/// <param name="time">Clock for the date line; <see cref="TimeProvider.System"/> when null.</param>
/// <param name="zone">The session's zone, read at every render; <see cref="TimeZoneInfo.Local"/> when null.</param>
public SystemPromptBuilder(string cwd, Func<IReadOnlyList<ITool>> activeTools, TimeProvider? time = null, Func<TimeZoneInfo>? zone = null)
{
_cwd = cwd;
_activeTools = activeTools;
_now = now ?? (() => DateTimeOffset.Now);
_time = time ?? TimeProvider.System;
_zone = zone ?? (() => TimeZoneInfo.Local);
}
public SystemPromptOptions DefaultOptions(string? basePrompt)
@@ -62,10 +70,21 @@ public sealed class SystemPromptBuilder
if (memory.Path is not null) AppendSection(sb, "memory", DefaultPrompts.MemoryPreamble(memory.Path) + (string.IsNullOrWhiteSpace(memory.Content) ? "" : "\n\n" + memory.Content));
}
AppendSection(sb, "environment", $"Current date: {_now():yyyy-MM-dd}\nWorking directory: {_cwd}");
AppendSection(sb, "environment", EnvironmentSection(_time.GetUtcNow(), _zone(), _cwd));
return sb.ToString().TrimEnd();
}
/// <summary>
/// Date (in <paramref name="zone"/>), zone and working directory. No time of day: the prompt is rendered per run and
/// a clock time would change the cached prefix every run; the model gets the time from the timestamp header instead.
/// </summary>
public static string EnvironmentSection(DateTimeOffset utcNow, TimeZoneInfo zone, string cwd)
{
var local = TimeZoneInfo.ConvertTime(utcNow, zone);
return string.Create(CultureInfo.InvariantCulture,
$"Current date: {local:yyyy-MM-dd} ({local:dddd})\nTime zone: {TimeZoneIds.Name(zone)} ({TimeZoneIds.FormatOffset(local.Offset)})\nWorking directory: {cwd}");
}
/// <summary>
/// Concatenates one project context file per directory walking up from cwd (nearest first): the first of
/// <see cref="DefaultPrompts.ProjectContextFiles"/> present. Size capped.

View File

@@ -15,4 +15,11 @@ public static class TimeZoneIds
var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
return id is not null && TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? id : null;
}
/// <summary>The zone's IANA id when it has one (<see cref="ToIana"/>), else its own id. For display.</summary>
public static string Name(TimeZoneInfo zone) => ToIana(zone) ?? zone.Id;
/// <summary><c>UTC+09:00</c>, <c>UTC-04:00</c>, <c>UTC+00:00</c>.</summary>
public static string FormatOffset(TimeSpan offset) =>
(offset < TimeSpan.Zero ? "UTC-" : "UTC+") + offset.Duration().ToString(@"hh\:mm", System.Globalization.CultureInfo.InvariantCulture);
}

View File

@@ -128,4 +128,37 @@ public class ClaudeArgsTests
Assert.Contains(name, ex.Message);
Assert.Equal(name, ClaudeArgs.FindDeniedVariable(parent));
}
[Fact]
public void BuildEnvironment_sets_TZ_from_the_zone_id()
{
if (OperatingSystem.IsWindows()) return; // Windows ignores TZ; see BuildEnvironment_never_sets_TZ_on_Windows
var env = ClaudeArgs.BuildEnvironment(new Dictionary<string, string?> { ["PATH"] = "/usr/bin" }, 1000, "Asia/Tokyo");
Assert.Equal("Asia/Tokyo", env["TZ"]);
}
[Fact]
public void BuildEnvironment_without_a_zone_leaves_TZ_unset_and_never_copies_the_parents()
{
var parent = new Dictionary<string, string?> { ["PATH"] = "/usr/bin", ["TZ"] = "America/Chicago" };
Assert.False(ClaudeArgs.BuildEnvironment(parent, 1000).ContainsKey("TZ"));
Assert.False(ClaudeArgs.AllowListed(parent, null).ContainsKey("TZ"));
var withZone = ClaudeArgs.BuildEnvironment(parent, 1000, "Asia/Tokyo");
Assert.NotEqual("America/Chicago", withZone.GetValueOrDefault("TZ"));
}
[Fact]
public void BuildEnvironment_never_sets_TZ_on_Windows()
{
if (!OperatingSystem.IsWindows()) return;
Assert.False(ClaudeArgs.BuildEnvironment(new Dictionary<string, string?>(), 1000, "Asia/Tokyo").ContainsKey("TZ"));
}
[Fact]
public void IanaZoneId_converts_and_rejects_Local()
{
Assert.Equal("Asia/Tokyo", ClaudeArgs.IanaZoneId(TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo")));
Assert.Equal("Asia/Tokyo", ClaudeArgs.IanaZoneId(TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time")));
Assert.Null(ClaudeArgs.IanaZoneId(TimeZoneInfo.CreateCustomTimeZone("Local", TimeSpan.FromHours(1), "Local", "Local")));
}
}

View File

@@ -0,0 +1,82 @@
using Microsoft.Extensions.DependencyInjection;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Features.Sessions;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
/// <summary>The session's zone reaches <c>claude -p</c> as <c>TZ</c>, per request. No test spawns the real CLI.</summary>
[Collection(FakeScripts.Collection)]
public sealed class ClaudeCodeTimeZoneTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("claude-tz-test-").FullName;
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
/// <summary>Appends $TZ to tz.log, drains stdin and prints a complete text-only turn.</summary>
private string FakeClaude()
{
var lines = new[]
{
Fixtures.Init().GetRawText(),
"{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_t\",\"model\":\"claude-sonnet-5\",\"usage\":{\"input_tokens\":7}}}}",
"{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}}",
"{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}}",
"{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_stop\",\"index\":0}}",
"{\"type\":\"stream_event\",\"event\":{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}}",
"{\"type\":\"stream_event\",\"event\":{\"type\":\"message_stop\"}}",
"{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"ok\",\"stop_reason\":\"end_turn\",\"usage\":{\"input_tokens\":7,\"output_tokens\":1}}",
};
var fixture = Path.Combine(_root, "text.jsonl");
File.WriteAllLines(fixture, lines);
var script = Path.Combine(_root, "fake-claude.sh");
File.WriteAllText(script, "#!/bin/sh\n" +
$"printf '%s\\n' \"${{TZ-unset}}\" >> \"{Path.Combine(_root, "tz.log")}\"\n" +
"cat > /dev/null\n" +
$"cat \"{fixture}\"\n");
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
return script;
}
[Fact]
public async Task The_claude_child_gets_the_session_zone_and_a_new_one_after_SetTimeZone()
{
if (!Fixtures.IsPosix) return;
var services = new ServiceCollection();
services.AddSingleton(new ClaudeCodeProviderOptions
{
ClaudePath = FakeClaude(),
GetEnv = _ => null,
Registry = new SessionRegistry(),
Coordinator = new ClaudeLoginCoordinator(),
ServerName = Fixtures.ServerName, // the name the recorded init fixtures use, as in ClaudeCodeApiTests
});
services.AddAgenticCore(o =>
{
o.AgentDir = Path.Combine(_root, "agent");
o.DiscoverExtensionAssemblies = false;
o.IncludeBuiltInTools = false; // no tools: the fake needs no proposal bridge
o.DefaultModel = "claude-code/sonnet";
o.SessionStoreFactory = _ => new InMemorySessionStore();
o.ProcessEnvironment.HardenHostProcess = false;
o.AddExtension<ClaudeCodeProviderExtension>();
});
await using var provider = services.BuildServiceProvider();
var cwd = Directory.CreateDirectory(Path.Combine(_root, "work")).FullName;
await using var session = await provider.GetRequiredService<IAgentSessionFactory>().CreateAsync(new AgentSessionOptions
{
Cwd = cwd,
TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo"),
});
await session.PromptAsync("hi");
session.SetTimeZone(TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"));
await session.PromptAsync("again");
Assert.Equal(["Asia/Tokyo", "Europe/Berlin"], File.ReadAllLines(Path.Combine(_root, "tz.log")));
}
}

View File

@@ -60,6 +60,7 @@ public class ProviderExtensionTests
{
public string Cwd => cwd;
public string SessionId => sessionId;
public TimeZoneInfo TimeZone { get; set; } = TimeZoneInfo.Local;
public bool HasUi => ui is not null;
public IUserInterface Ui => ui ?? NullUserInterface.Instance;
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
@@ -189,4 +190,24 @@ public class ProviderExtensionTests
var notice = Assert.Single(ui.Notices);
Assert.Equal(NotifyKind.Warning, notice.Kind);
}
[Fact]
public async Task Time_zone_id_follows_the_session_context_per_request()
{
var api = new FakeExtensionApi();
var extension = new ClaudeCodeProviderExtension(new ClaudeCodeProviderOptions { ClaudePath = null, Registry = new SessionRegistry(), GetEnv = _ => null });
await extension.ActivateAsync(api, CancellationToken.None);
var zoneId = extension.Api!.Options.TimeZoneId!;
Assert.Null(zoneId()); // no session yet
var ctx = new FakeContext(Path.GetTempPath(), "session-1") { TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo") };
await api.Raise(new SessionStartEvent(SessionStartReason.Startup), ctx);
Assert.Equal("Asia/Tokyo", zoneId());
ctx.TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); // SetTimeZone on the session
Assert.Equal("Europe/Berlin", zoneId());
await api.Raise(new SessionShutdownEvent(SessionShutdownReason.Quit), ctx);
Assert.Null(zoneId());
}
}

View File

@@ -0,0 +1,39 @@
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Extensions.Tests.Scheduler.Support;
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
public sealed class JobFormatTests
{
private static readonly DateTimeOffset Now = SchedulerHarness.Start; // Sat 26 Sep 14:03 Berlin
private static readonly TimeZoneInfo Berlin = SchedulerHarness.Berlin;
private static ScheduledJob Job(string id, string name, JobKind kind, JobSchedule schedule) => new()
{
Id = id, Target = "111", Name = name, Kind = kind, Schedule = schedule, CreatedAt = Now,
Text = kind == JobKind.Watcher ? null : name, Command = kind == JobKind.Watcher ? "check" : null,
};
[Fact]
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("`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) };
Assert.Equal("`w8r4t` 🔍 Price check · `*/10 * * * *` · next 14:10 · last: failed (exit 1)",
JobFormat.Line(new JobView(failing, new DateTimeOffset(2026, 9, 26, 12, 10, 0, TimeSpan.Zero)), Berlin, Now));
}
[Fact]
public void When_omits_the_date_today()
{
Assert.Equal("14:10", JobFormat.When(new DateTimeOffset(2026, 9, 26, 12, 10, 0, TimeSpan.Zero), Berlin, Now));
Assert.Equal("27 Sep 00:10", JobFormat.When(new DateTimeOffset(2026, 9, 26, 22, 10, 0, TimeSpan.Zero), Berlin, Now));
Assert.Equal("Sat 2026-09-26 14:03", JobFormat.Stamp(Now, Berlin));
Assert.Equal("exit 1, timed out", JobFormat.FailureReason(new JobResult(false, 1, true, null, Now)));
Assert.Equal("did not start", JobFormat.FailureReason(new JobResult(false, null, false, "failed to start", Now)));
}
}

View File

@@ -0,0 +1,431 @@
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
using Volcanic.AgenticCore.Extensions.Tests.Scheduler.Support;
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
public sealed class JobSchedulerTests
{
private static readonly TimeSpan Minute = TimeSpan.FromMinutes(1);
private static readonly DateTimeOffset Start = SchedulerHarness.Start; // 12:03Z = Sat 14:03 Berlin
private static ScheduledJob Stored(string id, JobKind kind, JobSchedule schedule, string? text = "Call the dentist", string target = "111") => new()
{
Id = id, Target = target, Name = text ?? "Watch", Kind = kind, Schedule = schedule, CreatedAt = Start.AddDays(-1),
Text = kind == JobKind.Watcher ? null : text, Command = kind == JobKind.Watcher ? "scripts/check.sh" : null,
};
private static NewJob Watcher(string cron = "* * * * *") => new("Price check", JobKind.Watcher, Cron: cron, Command: "scripts/price.sh");
[Fact]
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.StartAsync();
await Eventually.UntilAsync(() => h.Delivery.Texts.Count == 1, "the reminder send");
Assert.Single(await h.Store.LoadAsync(CancellationToken.None)); // not removed while the delivery is in progress
h.Delivery.HoldTexts.SetResult();
await h.Scheduler.IdleAsync();
Assert.Equal(("111", "⏰ Call the dentist (due 26 Sep 13:58, delivered late)"), Assert.Single(h.Delivery.Texts));
Assert.Empty(await h.Store.LoadAsync(CancellationToken.None));
Assert.Empty(h.Scheduler.List("111"));
}
[Fact]
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.StartAsync();
await h.Scheduler.IdleAsync();
Assert.Equal(("111", "⏰ Call the dentist"), Assert.Single(h.Delivery.Texts));
}
[Fact]
public async Task An_overdue_prompt_gets_the_offline_note()
{
await using var h = new SchedulerHarness();
await h.Store.SaveAsync([Stored("p3xz9", JobKind.Prompt, new JobSchedule(Start.AddHours(-1), null), text: "Summarise inbox/")], CancellationToken.None);
await h.StartAsync();
await h.Scheduler.IdleAsync();
Assert.Equal("[Scheduled job \"Summarise inbox/\" (p3xz9)]\nThis was due at 26 Sep 13:03; the bot was offline.\nSummarise inbox/",
Assert.Single(h.Delivery.Prompts).Prompt);
}
[Fact]
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) };
await h.Store.SaveAsync([job], CancellationToken.None);
await h.StartAsync();
await h.Scheduler.IdleAsync();
Assert.Empty(h.Delivery.Texts);
Assert.Equal(new DateTimeOffset(2026, 9, 26, 12, 10, 0, TimeSpan.Zero), Assert.Single(h.Scheduler.List("111")).NextRunAt);
}
[Fact]
public async Task Advancing_the_clock_fires_and_the_1_h_cap_rearms_without_firing()
{
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
Assert.Equal(Start.AddHours(1), h.Scheduler.NextWakeAt);
await h.AdvanceAsync(TimeSpan.FromHours(1));
Assert.Empty(h.Delivery.Texts);
Assert.Equal(Start.AddHours(2), h.Scheduler.NextWakeAt);
await h.AdvanceAsync(TimeSpan.FromHours(1));
Assert.Empty(h.Delivery.Texts);
Assert.Equal(Start.AddHours(3), h.Scheduler.NextWakeAt);
await h.AdvanceAsync(TimeSpan.FromHours(1));
Assert.Equal(("111", "⏰ Later"), Assert.Single(h.Delivery.Texts));
Assert.Empty(h.Scheduler.List("111"));
}
[Fact]
public async Task A_reminder_is_sent_as_text_and_a_prompt_as_a_prompt()
{
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.Equal(("111", $"[Scheduled job \"Summary\" ({prompt.Job.Id})]\nSummarise inbox/"), Assert.Single(h.Delivery.Prompts));
}
[Fact]
public async Task A_silent_watcher_makes_no_delivery_calls_and_only_updates_its_run_fields()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
var created = (await h.CreateAsync(Watcher())).Job;
h.Runner.Then(Enumerable.Repeat<ScriptOutcome>(new ScriptOutcome.Silent(), 10).Append(new ScriptOutcome.Output("price 48")).ToArray());
for (var i = 0; i < 10; i++) await h.AdvanceAsync(Minute);
Assert.Equal(10, h.Runner.Requests.Count);
Assert.Empty(h.Delivery.Prompts);
Assert.Empty(h.Delivery.Texts);
Assert.DoesNotContain(h.Delivery.Calls, c => c.StartsWith("RunPrompt", StringComparison.Ordinal) || c.StartsWith("SendText", StringComparison.Ordinal));
var stored = Assert.Single(await h.Store.LoadAsync(CancellationToken.None));
Assert.Equal(created, stored with { LastRunAt = null, LastResult = null }); // nothing but the run fields changed
Assert.Equal(Start.AddMinutes(10), stored.LastRunAt);
Assert.True(stored.LastResult!.Ok);
await h.AdvanceAsync(Minute);
Assert.Equal(("111", $"[Scheduled job \"Price check\" ({created.Id})] The watcher script printed:\nprice 48"), Assert.Single(h.Delivery.Prompts));
Assert.Empty(h.Delivery.Texts);
}
[Fact]
public async Task A_failure_is_reported_once_per_streak()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
var id = (await h.CreateAsync(Watcher())).Job.Id;
h.Runner.Then(
new ScriptOutcome.Failed(1, "boom", false),
new ScriptOutcome.Failed(1, "boom again", false),
new ScriptOutcome.Failed(2, "other", false),
new ScriptOutcome.Failed(null, "slow", true),
new ScriptOutcome.Silent(),
new ScriptOutcome.Failed(2, "back", false));
var counts = new List<int>();
for (var i = 0; i < 6; i++)
{
await h.AdvanceAsync(Minute);
counts.Add(h.Delivery.Texts.Count);
}
Assert.Equal([1, 1, 2, 3, 3, 4], counts);
Assert.Equal($"⚠️ Job \"Price check\" ({id}) failed (exit 1):\n```\nboom\n```", h.Delivery.Texts[0].Text);
Assert.Equal($"⚠️ Job \"Price check\" ({id}) failed (timed out):\n```\nslow\n```", h.Delivery.Texts[2].Text);
Assert.Empty(h.Delivery.Prompts); // failures never go through the model
}
[Fact]
public async Task Overlapping_occurrences_are_skipped_while_a_watcher_runs()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(Watcher());
h.Runner.Hold = new TaskCompletionSource();
h.Time.Advance(Minute);
await Eventually.UntilAsync(() => h.Runner.Requests.Count == 1, "the first run");
h.Time.Advance(Minute);
await h.Scheduler.PendingTick;
h.Time.Advance(Minute);
await h.Scheduler.PendingTick;
Assert.Equal(1, h.Scheduler.FiresStarted); // both occurrences were skipped by their ticks
Assert.Single(h.Runner.Requests);
h.Runner.Hold.SetResult();
h.Runner.Hold = null;
await h.Scheduler.IdleAsync();
await h.AdvanceAsync(Minute);
Assert.Equal(2, h.Runner.Requests.Count);
}
[Fact]
public async Task Overlapping_occurrences_are_skipped_while_a_prompt_run_is_held_open()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(new NewJob("Every minute", JobKind.Prompt, Cron: "* * * * *", Text: "tick"));
h.Delivery.HoldPrompts = new TaskCompletionSource();
h.Time.Advance(Minute);
await Eventually.UntilAsync(() => h.Delivery.Prompts.Count == 1, "the first prompt");
h.Time.Advance(Minute);
await h.Scheduler.PendingTick;
Assert.Equal(1, h.Scheduler.FiresStarted);
Assert.Single(h.Delivery.Prompts);
h.Delivery.HoldPrompts.SetResult();
h.Delivery.HoldPrompts = null;
await h.Scheduler.IdleAsync();
await h.AdvanceAsync(Minute);
Assert.Equal(2, h.Delivery.Prompts.Count);
}
[Fact]
public async Task Targets_are_isolated_in_List_and_CancelAsync()
{
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;
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.True(await h.Scheduler.CancelAsync("111", mine.Id, CancellationToken.None));
Assert.False(await h.Scheduler.CancelAsync("111", mine.Id, CancellationToken.None));
Assert.Equal([theirs.Id], (await h.Store.LoadAsync(CancellationToken.None)).Select(j => j.Id));
}
[Fact]
public async Task Cancel_kills_a_running_watcher_and_drops_its_output()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
var id = (await h.CreateAsync(Watcher())).Job.Id;
h.Runner.Fallback = new ScriptOutcome.Output("late output");
h.Runner.IgnoreCancellation = true; // the outcome still arrives after the cancel; it must be thrown away
h.Runner.Hold = new TaskCompletionSource();
h.Time.Advance(Minute);
await Eventually.UntilAsync(() => h.Runner.Requests.Count == 1, "the run");
Assert.True(await h.Scheduler.CancelAsync("111", id, CancellationToken.None));
Assert.Equal(1, h.Runner.CancellationsSeen);
h.Runner.Hold.SetResult();
await h.Scheduler.IdleAsync();
Assert.Empty(h.Delivery.Prompts);
Assert.Empty(await h.Store.LoadAsync(CancellationToken.None));
}
[Fact]
public async Task An_undeliverable_target_is_skipped_and_its_job_kept()
{
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"));
h.Delivery.Undeliverable.Add("111");
await h.AdvanceAsync(TimeSpan.FromMinutes(57)); // past 14:30 and 15:00 Berlin
Assert.Empty(h.Delivery.Texts);
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
Assert.Equal(2, (await h.Store.LoadAsync(CancellationToken.None)).Count);
}
[Fact]
public async Task StopAsync_kills_scripts_and_returns()
{
var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(Watcher());
h.Runner.Hold = new TaskCompletionSource();
h.Time.Advance(Minute);
await Eventually.UntilAsync(() => h.Runner.Requests.Count == 1, "the run");
await h.Scheduler.StopAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(1, h.Runner.CancellationsSeen);
await h.DisposeAsync();
}
[Fact]
public async Task StopAsync_gives_up_after_the_grace_period()
{
var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(Watcher());
h.Runner.IgnoreCancellation = true;
h.Runner.Hold = new TaskCompletionSource();
h.Time.Advance(Minute);
await Eventually.UntilAsync(() => h.Runner.Requests.Count == 1, "the run");
var stoppedAt = h.Time.GetUtcNow();
var stop = h.Scheduler.StopAsync(CancellationToken.None);
Assert.False(stop.IsCompleted); // waiting on the fake clock's grace period
// Second by second: the grace timer may be created after the first advances.
await Eventually.UntilAsync(() => { h.Time.Advance(TimeSpan.FromSeconds(1)); return stop.IsCompleted; }, "the stop after the grace period");
Assert.True(h.Time.GetUtcNow() - stoppedAt >= JobScheduler.StopGrace);
await stop;
h.Runner.Hold.SetResult();
await h.DisposeAsync();
}
[Fact]
public async Task A_stored_job_whose_zone_lookup_throws_does_not_stop_the_start()
{
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 * * * *")),
], CancellationToken.None);
h.Delivery.BadTargets.Add("not-a-chat");
await h.StartAsync();
Assert.True(h.Scheduler.IsStarted);
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));
}
[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.StartAsync();
await Eventually.UntilAsync(() => h.Delivery.Texts.Count == 1, "the reminder send");
await h.Scheduler.StopAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal("k7m2q", Assert.Single(await h.Store.LoadAsync(CancellationToken.None)).Id);
var delivery = new FakeJobDelivery();
await using (var restarted = new JobScheduler(h.Options, h.Runner, h.Redactor, h.Time, Microsoft.Extensions.Logging.Abstractions.NullLogger<JobScheduler>.Instance))
{
await restarted.StartAsync(delivery, CancellationToken.None);
await restarted.IdleAsync();
}
Assert.Equal(("111", "⏰ Call the dentist"), Assert.Single(delivery.Texts));
Assert.Empty(await h.Store.LoadAsync(CancellationToken.None));
await h.DisposeAsync();
}
[Fact]
public async Task A_zone_change_recomputes_cron_and_leaves_one_offs()
{
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 berlinNine = new DateTimeOffset(2026, 9, 27, 7, 0, 0, TimeSpan.Zero);
Assert.Equal(start.AddHours(1), h.Scheduler.NextWakeAt); // capped
h.Delivery.Zones["111"] = SchedulerHarness.Tokyo;
var change = await h.Scheduler.TimeZoneChangedAsync("111", CancellationToken.None);
Assert.Equal(new ZoneChange(1, 1), change);
var views = h.Scheduler.List("111").ToDictionary(v => v.Job.Id);
Assert.Equal(new DateTimeOffset(2026, 9, 27, 0, 0, 0, TimeSpan.Zero), views[cron.Id].NextRunAt); // 09:00 Tokyo
Assert.Equal(new DateTimeOffset(2026, 9, 27, 8, 0, 0, TimeSpan.Zero), views[once.Id].NextRunAt); // unchanged instant
Assert.Equal(berlinNine, Assert.Single(h.Scheduler.List("222")).NextRunAt);
Assert.Equal(new DateTimeOffset(2026, 9, 27, 0, 0, 0, TimeSpan.Zero), h.Scheduler.NextWakeAt); // re-armed
Assert.Equal(other.Id, h.Scheduler.List("222")[0].Job.Id);
}
[Fact]
public async Task Watcher_output_and_failure_tails_are_redacted()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(Watcher());
h.Runner.Then(new ScriptOutcome.Output("token=hunter2-secret"), new ScriptOutcome.Failed(1, "auth hunter2-secret rejected", false));
await h.AdvanceAsync(Minute);
await h.AdvanceAsync(Minute);
var prompt = Assert.Single(h.Delivery.Prompts).Prompt;
Assert.EndsWith("token=[REDACTED]", prompt);
var alert = Assert.Single(h.Delivery.Texts).Text;
Assert.Contains("auth [REDACTED] rejected", alert);
Assert.DoesNotContain("hunter2", prompt + alert);
Assert.Equal("auth [REDACTED] rejected", Assert.Single(await h.Store.LoadAsync(CancellationToken.None)).LastResult!.StderrTail);
}
[Fact]
public async Task Delivery_failures_are_logged_and_the_job_still_advances()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(new NewJob("Tick", JobKind.Reminder, Cron: "* * * * *", Text: "tick"));
h.Delivery.FailSends = new InvalidOperationException("Telegram is down");
await h.AdvanceAsync(Minute);
var view = Assert.Single(h.Scheduler.List("111"));
Assert.Equal(Start.AddMinutes(1), view.Job.LastRunAt);
Assert.Equal(Start.AddMinutes(2), view.NextRunAt);
}
[Fact]
public async Task CreateAsync_validates_and_caps_jobs_per_target()
{
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"));
var full = await Assert.ThrowsAsync<JobValidationException>(() => h.CreateAsync(new NewJob("c", JobKind.Reminder, Cron: "@daily", Text: "c")));
Assert.Equal("This chat already has 2 scheduled jobs, the maximum. Cancel one first.", full.Message);
await h.CreateAsync(new NewJob("c", JobKind.Reminder, Cron: "@daily", Text: "c"), "222"); // other targets have their own cap
await Assert.ThrowsAsync<JobValidationException>(() => h.CreateAsync(new NewJob("bad", JobKind.Reminder, Cron: "0 0 9 * * *", Text: "x"), "333"));
var ids = h.Scheduler.List("111").Select(v => v.Job.Id).ToList();
Assert.All(ids, id => Assert.Matches("^[abcdefghjkmnpqrstuvwxyz23456789]{5}$", id));
}
[Fact]
public async Task Watcher_runs_with_the_target_workspace_zone_and_timeout()
{
await using var h = new SchedulerHarness();
h.Delivery.Zones["111"] = SchedulerHarness.Tokyo;
await h.StartAsync();
var job = (await h.CreateAsync(Watcher())).Job;
await h.CreateAsync(new NewJob("Quick", JobKind.Watcher, Cron: "* * * * *", Command: "true", TimeoutSeconds: 5), "222");
await h.AdvanceAsync(Minute);
var requests = h.Runner.Requests.OrderBy(r => r.Target).ToList();
Assert.Equal(new ScriptRequest("scripts/price.sh", Path.Combine(h.Delivery.WorkspaceRoot, "111"), TimeSpan.FromSeconds(300), "111", job.Id, "Asia/Tokyo"), requests[0]);
Assert.Equal(TimeSpan.FromSeconds(5), requests[1].Timeout);
Assert.Equal("Europe/Berlin", requests[1].TimeZoneId);
}
}

View File

@@ -0,0 +1,114 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Extensions.Scheduler;
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
public sealed class JobStoreTests : IDisposable
{
private static readonly DateTimeOffset Now = new(2026, 9, 26, 14, 3, 0, TimeSpan.Zero);
private readonly string _root = Directory.CreateTempSubdirectory("jobstore-").FullName;
private readonly FakeTimeProvider _time = new(Now);
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()
{
Id = id, Target = target, Name = "Call the dentist", Kind = JobKind.Reminder, Text = "Call the dentist",
Schedule = new JobSchedule(Now.AddDays(1), null), CreatedAt = Now,
};
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
[Fact]
public async Task Missing_file_loads_empty() => Assert.Empty(await Store().LoadAsync(CancellationToken.None));
[Fact]
public async Task Round_trip_keeps_jobs_and_leaves_no_temp_file()
{
var watcher = new ScheduledJob
{
Id = "w8r4t", Target = "111", Name = "Price check", Kind = JobKind.Watcher, Command = "scripts/price.sh",
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);
Assert.Equal([Reminder("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);
var json = await File.ReadAllTextAsync(StorePath);
Assert.Contains("\"version\": 1", json);
Assert.Contains("\"jobs\": [", json);
Assert.Contains("\"kind\": \"reminder\"", json);
Assert.NotEqual(0xEF, (await File.ReadAllBytesAsync(StorePath))[0]); // no BOM
}
[Fact]
public async Task Corrupt_file_is_set_aside_and_loading_starts_empty()
{
Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!);
await File.WriteAllTextAsync(StorePath, "{ not json");
Assert.Empty(await Store().LoadAsync(CancellationToken.None));
Assert.False(File.Exists(StorePath));
Assert.Equal("{ not json", await File.ReadAllTextAsync(StorePath + ".corrupt-20260926140300"));
}
[Fact]
public async Task Invalid_entries_are_dropped()
{
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": "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" }
] }
""");
var jobs = await Store().LoadAsync(CancellationToken.None);
Assert.Equal("ok", Assert.Single(jobs).Name);
}
[Fact]
public async Task An_entry_that_does_not_deserialise_is_dropped_without_setting_the_file_aside()
{
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": "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 * * *" } },
null,
42
] }
""");
var jobs = await Store().LoadAsync(CancellationToken.None);
Assert.Equal("good1", Assert.Single(jobs).Id);
Assert.True(File.Exists(StorePath));
}
[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))));
Assert.Single(await Store().LoadAsync(CancellationToken.None));
Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(StorePath)!, "*.tmp"));
}
}

View File

@@ -0,0 +1,69 @@
using Volcanic.AgenticCore.Extensions.Scheduler;
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
public sealed class JobValidationTests
{
private static readonly DateTimeOffset Now = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero); // 14:03 Berlin
private static readonly TimeZoneInfo Berlin = ScheduleTests.Berlin;
private static string Error(NewJob job) =>
Assert.Throws<JobValidationException>(() => JobValidation.ValidateNew(job, Now, Berlin)).Message;
[Fact]
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);
Assert.Equal(new DateTimeOffset(2026, 9, 26, 12, 2, 30, TimeSpan.Zero), schedule.At); // within 60 s of grace
}
[Theory]
[InlineData(null, null)]
[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")));
[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("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)));
Assert.Equal("timeoutSeconds must be between 1 and 3600.", Error(new NewJob("x", JobKind.Watcher, Cron: cron, Command: "ls", TimeoutSeconds: 0)));
Assert.Equal("timeoutSeconds must be between 1 and 3600.", Error(new NewJob("x", JobKind.Watcher, Cron: cron, Command: "ls", TimeoutSeconds: 3601)));
Assert.Equal(new JobSchedule(null, cron), JobValidation.ValidateNew(new NewJob("x", JobKind.Watcher, Cron: cron, Command: "ls", TimeoutSeconds: 3600), Now, Berlin));
}
[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")));
}
[Fact]
public void Text_is_capped_at_4000() =>
Assert.Equal("text is 4001 characters; the limit is 4000.", Error(new NewJob("x", JobKind.Prompt, Cron: "@daily", Text: new string('t', 4001))));
[Fact]
public void Stored_jobs_are_checked_without_the_future_rule()
{
var job = new ScheduledJob
{
Id = "k7m2q", Target = "111", Name = "Old", Kind = JobKind.Reminder, 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
Assert.NotNull(JobValidation.CheckStored(job with { Schedule = new JobSchedule(Now, "0 9 * * *") }));
Assert.NotNull(JobValidation.CheckStored(job with { Schedule = new JobSchedule(null, "0 0 9 * * *") }));
Assert.NotNull(JobValidation.CheckStored(job with { Kind = JobKind.Watcher }));
Assert.NotNull(JobValidation.CheckStored(job with { Id = "" }));
}
}

View File

@@ -0,0 +1,101 @@
using Volcanic.AgenticCore.Extensions.Scheduler;
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
public sealed class ScheduleTests
{
internal static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
internal static readonly TimeZoneInfo Tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
private static DateTimeOffset Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, TimeSpan.Zero);
private static List<DateTimeOffset> Occurrences(string cron, DateTimeOffset from, DateTimeOffset to, TimeZoneInfo zone)
{
var list = new List<DateTimeOffset>();
for (var next = Schedules.NextCron(cron, from, zone); next is { } n && n < to; next = Schedules.NextCron(cron, n, zone)) list.Add(n);
return list;
}
[Fact]
public void Next_cron_occurrence_is_strictly_after_from_in_the_zone()
{
var nineBerlin = Utc(2026, 9, 26, 7, 0); // 09:00 CEST
Assert.Equal(Utc(2026, 9, 27, 7, 0), Schedules.NextCron("0 9 * * *", nineBerlin, Berlin));
Assert.Equal(Utc(2026, 9, 27, 0, 0), Schedules.NextCron("0 9 * * *", nineBerlin, Tokyo)); // 09:00 JST on the 27th
}
[Fact]
public void Spring_forward_day_fires_once_at_the_transition()
{
// 2026-03-29: Berlin skips 02:00–03:00. 02:30 doesn't exist, so the job fires at the transition (01:00Z).
var fires = Occurrences("30 2 * * *", Utc(2026, 3, 28, 12, 0), Utc(2026, 3, 30, 12, 0), Berlin);
Assert.Equal([Utc(2026, 3, 29, 1, 0), Utc(2026, 3, 30, 0, 30)], fires);
}
[Fact]
public void Fall_back_day_fires_once()
{
// 2026-10-25: Berlin repeats 02:00–03:00; 02:30 happens twice but the job fires once that day.
var fires = Occurrences("30 2 * * *", Utc(2026, 10, 24, 12, 0), Utc(2026, 10, 26, 12, 0), Berlin);
Assert.Single(fires, f => TimeZoneInfo.ConvertTime(f, Berlin).Day == 25);
}
[Fact]
public void At_without_an_offset_is_read_in_the_zone()
{
Assert.Equal(Utc(2026, 9, 27, 7, 0), Schedules.ParseAt("2026-09-27T09:00", Berlin));
Assert.Equal(Utc(2026, 9, 27, 0, 0), Schedules.ParseAt("2026-09-27T09:00", Tokyo));
Assert.Equal(Utc(2026, 9, 27, 7, 0), Schedules.ParseAt("2026-09-27 09:00", Berlin));
Assert.Equal(Utc(2026, 9, 26, 22, 0), Schedules.ParseAt("2026-09-27", Berlin)); // a bare date is local midnight, not an offset
}
[Fact]
public void At_with_an_offset_is_used_as_given()
{
Assert.Equal(Utc(2026, 9, 27, 4, 0), Schedules.ParseAt("2026-09-27T09:00+05:00", Berlin));
Assert.Equal(Utc(2026, 9, 27, 9, 0), Schedules.ParseAt("2026-09-27T09:00Z", Tokyo));
Assert.Equal(Utc(2026, 9, 27, 7, 0), Schedules.ParseAt("2026-09-27T09:00:00.000+0200", Tokyo));
Assert.Equal(Utc(2026, 9, 27, 11, 0), Schedules.ParseAt("2026-09-27 09:00-02", Tokyo));
}
[Theory]
[InlineData("09:00")] // no date: would take the machine's date, not the chat's
[InlineData("09:00+02:00")]
[InlineData("09/10/2026")] // culture formats aren't ISO 8601
[InlineData("27.09.2026 09:00")]
[InlineData("2026-09-27T09:00+15:00")] // beyond ±14:00
public void At_without_a_date_or_not_in_ISO_8601_is_rejected(string at)
{
var ex = Assert.Throws<JobValidationException>(() => Schedules.ParseAt(at, Berlin));
Assert.Contains("ISO 8601 with the date", ex.Message);
}
[Theory]
[InlineData("not a cron", "has 3 fields")]
[InlineData("61 * * * *", "is invalid")]
[InlineData("0 0 8 * * *", "seconds aren't supported")]
[InlineData("H * * * *", "H (hash)")]
[InlineData("0 H/2 * * *", "H (hash)")]
[InlineData("", "cron is empty")]
public void Invalid_cron_is_rejected_with_a_readable_message(string cron, string fragment)
{
var ex = Assert.Throws<JobValidationException>(() => Schedules.ParseCron(cron));
Assert.Contains(fragment, ex.Message);
if (cron.Length > 0) Assert.Contains("minute hour day-of-month month day-of-week", ex.Message);
}
[Theory]
[InlineData("@daily")]
[InlineData("@hourly")]
[InlineData("0 8 * * THU")]
[InlineData("*/10 * * * *")]
[InlineData("0 8 * * 1-5")]
public void Macros_and_day_names_parse(string cron) => Schedules.ParseCron(cron);
[Fact]
public void Unparseable_at_is_rejected()
{
var ex = Assert.Throws<JobValidationException>(() => Schedules.ParseAt("tomorrow at nine", Berlin));
Assert.Contains("ISO 8601", ex.Message);
}
}

View File

@@ -0,0 +1,112 @@
using Microsoft.Extensions.DependencyInjection;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Extensions.Tests.Scheduler.Support;
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
public sealed class ScheduleToolTests
{
private static async Task<SchedulerHarness> StartedAsync()
{
var h = new SchedulerHarness(); // Sat 2026-09-26 12:03Z
h.Delivery.Zones["111"] = SchedulerHarness.Berlin;
h.Delivery.Zones["222"] = SchedulerHarness.Tokyo;
await h.StartAsync();
return h;
}
private static Task<ToolResult> Run(SchedulerHarness h, string target, object args) =>
new ScheduleTool(h.Scheduler, target).ExecuteAsync(
new ToolCallContent("call-1", ScheduleTool.ToolName, VolcanicJson.ToElement(args)),
new ToolInvocation("call-1", new SchedulerStubContext(), null), CancellationToken.None);
[Fact]
public async Task Create_list_and_cancel_through_the_tool()
{
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 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.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);
var cancelled = await Run(h, "111", new { action = "cancel", id });
Assert.Equal($"Cancelled {id} (Call the dentist).", cancelled.TextContent);
Assert.Equal("No scheduled jobs.\nNow: Sat 2026-09-26 14:03 (Europe/Berlin)", (await Run(h, "111", new { action = "list" })).TextContent);
}
[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":"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.")]
public async Task Validation_errors_become_error_results(string json, string fragment)
{
await using var h = await StartedAsync();
var tool = new ScheduleTool(h.Scheduler, "111");
var ex = await Assert.ThrowsAsync<ToolExecutionException>(() => tool.ExecuteAsync(
new ToolCallContent("c", ScheduleTool.ToolName, VolcanicJson.Parse(json)), new ToolInvocation("c", new SchedulerStubContext(), null), CancellationToken.None));
Assert.Contains(fragment, ex.Message);
Assert.Empty(h.Scheduler.List("111"));
}
[Fact]
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" });
var theirs = Assert.Single(h.Scheduler.List("222")).Job.Id;
Assert.StartsWith("No scheduled jobs.", (await Run(h, "111", new { action = "list" })).TextContent);
var ex = await Assert.ThrowsAsync<ToolExecutionException>(() => Run(h, "111", new { action = "cancel", id = theirs }));
Assert.Equal($"No job {theirs} in this chat.", ex.Message);
Assert.Single(h.Scheduler.List("222"));
}
[Fact]
public async Task The_now_line_uses_the_targets_zone()
{
await using var h = await StartedAsync();
Assert.EndsWith("Now: Sat 2026-09-26 21:03 (Asia/Tokyo)", (await Run(h, "222", new { action = "list" })).TextContent);
}
[Fact]
public async Task An_offset_less_at_is_read_in_the_targets_zone()
{
await using var h = await StartedAsync();
await Run(h, "222", new { action = "create", name = "Tokyo", kind = "prompt", at = "2026-09-27T09:00", text = "Good morning" });
Assert.Equal(new DateTimeOffset(2026, 9, 27, 0, 0, 0, TimeSpan.Zero), Assert.Single(h.Scheduler.List("222")).Job.Schedule.At);
}
[Fact]
public async Task Snippet_and_guidelines_match_the_spec()
{
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(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));
}
[Fact]
public void Discovery_without_a_host_scheduler_cannot_construct_the_extension()
{
using var provider = new ServiceCollection().BuildServiceProvider();
Assert.Throws<InvalidOperationException>(() => ActivatorUtilities.CreateInstance(provider, typeof(SchedulerExtension)));
}
}

View File

@@ -0,0 +1,184 @@
using System.Collections;
using System.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Features.Processes;
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
/// <summary>Real processes (Unix only, like the other script tests) with a real ProcessSpawnPolicy over an injected environment.</summary>
public sealed class ScriptRunnerTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("scriptrunner-").FullName;
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
/// <summary>The real environment (so the shell starts) plus host secrets the default deny-list must remove.</summary>
private static Dictionary<string, string?> Snapshot()
{
var env = new Dictionary<string, string?>(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
foreach (DictionaryEntry e in Environment.GetEnvironmentVariables()) env[(string)e.Key] = (string?)e.Value;
env.Remove("TZ");
env["TELEGRAM_BOT_TOKEN"] = "123:bot-token";
env["OPENAI_API_KEY"] = "sk-test-key";
return env;
}
private ScriptRunner Runner(Action<ProcessEnvironmentOptions>? configure = null, SchedulerOptions? options = null)
{
var env = Snapshot();
var processOptions = new ProcessEnvironmentOptions { HardenHostProcess = false };
configure?.Invoke(processOptions);
return new ScriptRunner(options ?? new SchedulerOptions(), new ProcessSpawnPolicy(processOptions, null, () => env), NullLogger<ScriptRunner>.Instance);
}
private ScriptRequest Request(string command, TimeSpan? timeout = null, string? zone = null, string? cwd = null) =>
new(command, cwd ?? Path.Combine(_root, "workspace"), timeout ?? TimeSpan.FromSeconds(30), "111", "w8r4t", zone);
[Fact]
public async Task Output_is_trimmed_stdout()
{
if (OperatingSystem.IsWindows()) return;
var outcome = await Runner().RunAsync(Request("printf ' price 48\\n\\n'"), CancellationToken.None);
Assert.Equal(new ScriptOutcome.Output("price 48"), outcome);
}
[Fact]
public async Task Empty_output_is_silent()
{
if (OperatingSystem.IsWindows()) return;
Assert.IsType<ScriptOutcome.Silent>(await Runner().RunAsync(Request("true"), CancellationToken.None));
Assert.IsType<ScriptOutcome.Silent>(await Runner().RunAsync(Request("printf ' \\n'; echo noise >&2"), CancellationToken.None));
}
[Fact]
public async Task Non_zero_exit_is_a_failure_with_the_last_20_stderr_lines()
{
if (OperatingSystem.IsWindows()) return;
var outcome = await Runner().RunAsync(Request("for i in $(seq 1 30); do echo line$i >&2; done; exit 3"), CancellationToken.None);
var failed = Assert.IsType<ScriptOutcome.Failed>(outcome);
Assert.Equal(3, failed.ExitCode);
Assert.False(failed.TimedOut);
Assert.Equal(Enumerable.Range(11, 20).Select(i => $"line{i}"), failed.StderrTail.Split('\n'));
}
[Fact]
public async Task Timeout_kills_the_process_tree()
{
if (OperatingSystem.IsWindows()) return;
var pidFile = Path.Combine(_root, "child.pid");
var stopwatch = Stopwatch.StartNew();
var outcome = await Runner().RunAsync(Request($"sleep 30 & echo $! > '{pidFile}'; wait", TimeSpan.FromSeconds(1)), CancellationToken.None);
var failed = Assert.IsType<ScriptOutcome.Failed>(outcome);
Assert.True(failed.TimedOut);
Assert.Null(failed.ExitCode);
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(10), $"took {stopwatch.Elapsed}");
var child = int.Parse(File.ReadAllText(pidFile).Trim(), System.Globalization.CultureInfo.InvariantCulture);
await Task.Delay(200);
Assert.True(IsGone(child), "the background sleep must be killed with the tree");
}
private static bool IsGone(int pid)
{
try { return Process.GetProcessById(pid).HasExited; }
catch (ArgumentException) { return true; }
}
[Fact]
public async Task Cancellation_kills_the_script_and_throws()
{
if (OperatingSystem.IsWindows()) return;
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
var stopwatch = Stopwatch.StartNew();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => Runner().RunAsync(Request("sleep 30"), cts.Token));
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(10));
}
[Fact]
public async Task Capture_is_capped_to_head_and_tail()
{
if (OperatingSystem.IsWindows()) return;
var outcome = await Runner().RunAsync(Request("head -c 200000 /dev/zero | tr '\\0' a; printf END"), CancellationToken.None);
var text = Assert.IsType<ScriptOutcome.Output>(outcome).Text;
Assert.StartsWith(new string('a', 100), text);
Assert.EndsWith("aEND", text);
Assert.Contains("characters omitted", text);
Assert.True(text.Length < OutputCapture.HeadChars + OutputCapture.TailChars + 200);
}
[Fact]
public async Task Host_secrets_are_absent_and_TZ_is_the_target_zone()
{
if (OperatingSystem.IsWindows()) return;
var outcome = await Runner().RunAsync(
Request("printf '%s|%s|%s' \"${TELEGRAM_BOT_TOKEN-unset}\" \"${OPENAI_API_KEY-unset}\" \"$TZ\"", zone: "Asia/Tokyo"),
CancellationToken.None);
Assert.Equal(new ScriptOutcome.Output("unset|unset|Asia/Tokyo"), outcome);
}
[Fact]
public async Task A_throwing_host_hook_fails_without_starting_a_process()
{
var marker = Path.Combine(_root, "workspace", "started");
var runner = Runner(o => o.Hooks.Add(_ => throw new InvalidOperationException("nope")));
var outcome = await runner.RunAsync(Request(OperatingSystem.IsWindows() ? $"type nul > \"{marker}\"" : $"touch '{marker}'"), CancellationToken.None);
Assert.Equal(new ScriptOutcome.Failed(null, "process environment hook failed: nope", false), outcome);
Assert.False(File.Exists(marker));
}
[Fact]
public async Task Cwd_is_the_workspace()
{
if (OperatingSystem.IsWindows()) return;
var cwd = Path.Combine(_root, "not-yet-created");
var outcome = await Runner().RunAsync(Request("basename \"$(pwd)\"", cwd: cwd), CancellationToken.None);
Assert.Equal(new ScriptOutcome.Output("not-yet-created"), outcome);
}
[Fact]
public async Task A_shell_that_fails_to_start_is_a_failure()
{
var shell = Path.Combine(_root, "no-such-shell");
var outcome = await Runner(options: new SchedulerOptions { Shell = [shell, "-c"] }).RunAsync(Request("true"), CancellationToken.None);
var failed = Assert.IsType<ScriptOutcome.Failed>(outcome);
Assert.StartsWith($"failed to start {shell}: ", failed.StderrTail);
Assert.Null(failed.ExitCode);
}
}
public sealed class OutputCaptureTests
{
[Fact]
public void Keeps_the_head_and_tail_and_counts_the_rest()
{
var capture = new OutputCapture();
capture.Append(new string('h', OutputCapture.HeadChars));
capture.Append(new string('x', 1000));
capture.Append(new string('t', OutputCapture.TailChars));
var text = capture.ToString();
Assert.StartsWith(new string('h', OutputCapture.HeadChars), text);
Assert.EndsWith(new string('t', OutputCapture.TailChars), text);
Assert.Contains("[… 1000 characters omitted …]", text);
}
[Fact]
public void Short_output_is_kept_whole()
{
var capture = new OutputCapture();
capture.Append("one\n");
capture.Append("two");
Assert.Equal("one\ntwo", capture.ToString());
}
}

View File

@@ -0,0 +1,184 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.Scheduler;
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler.Support;
/// <summary>Records every delivery call; can hold sends and prompt runs open, fail sends, and switch a target's zone.</summary>
internal sealed class FakeJobDelivery : IJobDelivery
{
private readonly Lock _lock = new();
private readonly List<(string Target, string Text)> _texts = [];
private readonly List<(string Target, string Prompt)> _prompts = [];
private readonly List<string> _calls = [];
public Dictionary<string, TimeZoneInfo> Zones { get; } = new();
public TimeZoneInfo DefaultZone { get; set; } = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public HashSet<string> Undeliverable { get; } = [];
/// <summary>Targets whose <see cref="TimeZoneFor"/> throws (a store entry the host can't map).</summary>
public HashSet<string> BadTargets { get; } = [];
public TaskCompletionSource? HoldTexts { get; set; }
public TaskCompletionSource? HoldPrompts { get; set; }
public Exception? FailSends { get; set; }
public string WorkspaceRoot { get; set; } = Path.Combine(Path.GetTempPath(), "fake-workspaces");
public IReadOnlyList<(string Target, string Text)> Texts { get { lock (_lock) return _texts.ToList(); } }
public IReadOnlyList<(string Target, string Prompt)> Prompts { get { lock (_lock) return _prompts.ToList(); } }
/// <summary>"SendText:111", "RunPrompt:111", "WorkspaceFor:111", "TimeZoneFor:111", "IsDeliverable:111".</summary>
public IReadOnlyList<string> Calls { get { lock (_lock) return _calls.ToList(); } }
public async Task SendTextAsync(string target, string markdown, CancellationToken cancellationToken)
{
lock (_lock) { _calls.Add("SendText:" + target); _texts.Add((target, markdown)); }
if (HoldTexts is { } hold) await hold.Task.WaitAsync(cancellationToken);
if (FailSends is { } error) throw error;
}
public async Task RunPromptAsync(string target, string prompt, CancellationToken cancellationToken)
{
lock (_lock) { _calls.Add("RunPrompt:" + target); _prompts.Add((target, prompt)); }
if (HoldPrompts is { } hold) await hold.Task.WaitAsync(cancellationToken);
}
public string WorkspaceFor(string target)
{
lock (_lock) _calls.Add("WorkspaceFor:" + target);
return Path.Combine(WorkspaceRoot, target);
}
public bool IsDeliverable(string target)
{
lock (_lock) _calls.Add("IsDeliverable:" + target);
return !Undeliverable.Contains(target);
}
public TimeZoneInfo TimeZoneFor(string target)
{
lock (_lock) _calls.Add("TimeZoneFor:" + target);
if (BadTargets.Contains(target)) throw new FormatException($"bad target {target}");
return Zones.GetValueOrDefault(target, DefaultZone);
}
}
/// <summary>Returns queued outcomes (then <see cref="Fallback"/>); can hold runs open, honouring or ignoring cancellation.</summary>
internal sealed class FakeScriptRunner : IScriptRunner
{
private readonly Lock _lock = new();
private readonly Queue<ScriptOutcome> _outcomes = new();
private readonly List<ScriptRequest> _requests = [];
private int _cancellations;
public ScriptOutcome Fallback { get; set; } = new ScriptOutcome.Silent();
public TaskCompletionSource? Hold { get; set; }
public bool IgnoreCancellation { get; set; }
public IReadOnlyList<ScriptRequest> Requests { get { lock (_lock) return _requests.ToList(); } }
public int CancellationsSeen => Volatile.Read(ref _cancellations);
public FakeScriptRunner Then(params ScriptOutcome[] outcomes)
{
lock (_lock) foreach (var o in outcomes) _outcomes.Enqueue(o);
return this;
}
public async Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken)
{
ScriptOutcome outcome;
lock (_lock)
{
_requests.Add(request);
outcome = _outcomes.Count > 0 ? _outcomes.Dequeue() : Fallback;
}
if (Hold is { } hold)
{
if (IgnoreCancellation)
{
using var _ = cancellationToken.Register(() => Interlocked.Increment(ref _cancellations));
await hold.Task;
}
else
{
try { await hold.Task.WaitAsync(cancellationToken); }
catch (OperationCanceledException) { Interlocked.Increment(ref _cancellations); throw; }
}
}
return outcome;
}
}
internal sealed class FakeRedactor(params string[] secrets) : ISecretRedactor
{
public string Redact(string text) => secrets.Aggregate(text, (t, s) => t.Replace(s, "[REDACTED]", StringComparison.Ordinal));
}
/// <summary>A <see cref="JobScheduler"/> on a <see cref="FakeTimeProvider"/> at Sat 2026-09-26 14:03 Berlin, a temp store and the fakes.</summary>
internal sealed class SchedulerHarness : IAsyncDisposable
{
public static readonly DateTimeOffset Start = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero);
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public static readonly TimeZoneInfo Tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
public SchedulerHarness(DateTimeOffset? start = null, Action<SchedulerOptions>? configure = null)
{
Time = new FakeTimeProvider(start ?? Start);
Options = new SchedulerOptions { StorePath = Path.Combine(Root, "scheduler", "jobs.json") };
configure?.Invoke(Options);
Delivery.WorkspaceRoot = Path.Combine(Root, "workspaces");
Scheduler = new JobScheduler(Options, Runner, Redactor, Time, NullLogger<JobScheduler>.Instance);
}
public string Root { get; } = Directory.CreateTempSubdirectory("scheduler-").FullName;
public FakeTimeProvider Time { get; }
public SchedulerOptions Options { get; }
public FakeJobDelivery Delivery { get; } = new();
public FakeScriptRunner Runner { get; } = new();
public FakeRedactor Redactor { get; } = new("hunter2-secret");
public JobScheduler Scheduler { get; }
public JobStore Store => new(Options.StorePath!, Time, NullLogger.Instance);
public Task StartAsync() => Scheduler.StartAsync(Delivery, CancellationToken.None);
public Task<JobView> CreateAsync(NewJob job, string target = "111") => Scheduler.CreateAsync(target, job, CancellationToken.None);
/// <summary>Advance the clock and wait for the tick and every fire it started (don't use while a fire is held open).</summary>
public async Task AdvanceAsync(TimeSpan by)
{
Time.Advance(by);
await Scheduler.IdleAsync();
}
public async ValueTask DisposeAsync()
{
Runner.Hold?.TrySetResult();
Delivery.HoldTexts?.TrySetResult();
Delivery.HoldPrompts?.TrySetResult();
await Scheduler.StopAsync(CancellationToken.None);
try { Directory.Delete(Root, recursive: true); } catch (IOException) { }
}
}
/// <summary>The context a tool gets; the schedule tool reads nothing from it (the zone comes from the scheduler).</summary>
internal sealed class SchedulerStubContext : IExtensionContext
{
public string Cwd => Path.GetTempPath();
public string SessionId => "scheduler-test";
public bool HasUi => false;
public IUserInterface Ui => NullUserInterface.Instance;
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
public IModelRegistry ModelRegistry => throw new NotSupportedException();
public Model? Model => null;
public ThinkingLevel ThinkingLevel => ThinkingLevel.Off;
public CancellationToken Signal => CancellationToken.None;
public ILogger Logger => NullLogger.Instance;
public bool IsIdle() => true;
public void Abort() { }
public bool HasPendingMessages() => false;
public ContextUsage? GetContextUsage() => null;
public string GetSystemPrompt() => "";
public void Compact(CompactOptions? options = null) { }
}

View File

@@ -24,6 +24,7 @@
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj" />
<ProjectReference Include="../Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,85 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Telegram.Chats;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class ChatSettingsStoreTests : IDisposable
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private readonly string _root = Directory.CreateTempSubdirectory("chat-settings-").FullName;
private string FilePath => ChatSettingsStore.DefaultPath(_root);
private ChatSettingsStore Store() =>
new(FilePath, Berlin, NullLogger<ChatSettingsStore>.Instance, new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 14, 3, 0, TimeSpan.Zero)));
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
[Fact]
public void Missing_file_uses_the_default()
{
var store = Store();
Assert.Same(Berlin, store.TimeZoneFor(1001));
Assert.False(store.HasOwnZone(1001));
Assert.EndsWith(Path.Combine("telegram", "chat-settings.json"), FilePath);
}
[Fact]
public async Task Set_persists_and_a_new_store_reads_it()
{
await Store().SetTimeZoneAsync(1001, "Asia/Tokyo", CancellationToken.None);
var reloaded = Store();
Assert.Equal("Asia/Tokyo", reloaded.TimeZoneFor(1001).Id);
Assert.True(reloaded.HasOwnZone(1001));
Assert.Same(Berlin, reloaded.TimeZoneFor(2002));
Assert.Equal(["chat-settings.json"], Directory.GetFiles(Path.GetDirectoryName(FilePath)!).Select(Path.GetFileName)); // no temp file left
}
[Fact]
public async Task File_format_is_versioned_camel_case()
{
await Store().SetTimeZoneAsync(1001, "Asia/Tokyo", CancellationToken.None);
var json = await File.ReadAllTextAsync(FilePath);
Assert.Contains("\"version\": 1", json);
Assert.Contains("\"1001\": {", json);
Assert.Contains("\"timeZone\": \"Asia/Tokyo\"", json);
}
[Fact]
public async Task Null_removes_the_entry()
{
var store = Store();
await store.SetTimeZoneAsync(1001, "Asia/Tokyo", CancellationToken.None);
await store.SetTimeZoneAsync(1001, null, CancellationToken.None);
Assert.False(store.HasOwnZone(1001));
Assert.False(Store().HasOwnZone(1001));
}
[Fact]
public void Corrupt_file_is_set_aside_and_loading_starts_empty()
{
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
File.WriteAllText(FilePath, "{ nope");
var store = Store();
Assert.False(store.HasOwnZone(1001));
Assert.True(File.Exists(FilePath + ".corrupt-20260926140300"));
}
[Fact]
public void Unknown_stored_id_is_ignored()
{
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
File.WriteAllText(FilePath, """{ "version": 1, "chats": { "1001": { "timeZone": "Mars/Olympus" }, "2002": { "timeZone": "Asia/Tokyo" } } }""");
var store = Store();
Assert.Same(Berlin, store.TimeZoneFor(1001));
Assert.Equal("Asia/Tokyo", store.TimeZoneFor(2002).Id);
}
}

View File

@@ -0,0 +1,33 @@
using Volcanic.AgenticCore.Telegram.Chats;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class ChatTimeZonesTests
{
[Fact]
public void Iana_ids_parse()
{
Assert.True(ChatTimeZones.TryParse(" Asia/Tokyo ", out var zone, out _));
Assert.Equal("Asia/Tokyo", Volcanic.AgenticCore.Hosting.TimeZoneIds.ToIana(zone));
}
[Fact]
public void Windows_ids_are_converted_to_iana()
{
Assert.True(ChatTimeZones.TryParse("Tokyo Standard Time", out var zone, out _));
Assert.Equal("Asia/Tokyo", Volcanic.AgenticCore.Hosting.TimeZoneIds.ToIana(zone));
}
[Theory]
[InlineData("Local")]
[InlineData("+03:00")]
[InlineData("GMT+3")]
[InlineData("UTC-5")]
[InlineData("Mars/Olympus")]
[InlineData("")]
public void Rejects_local_offsets_and_unknown_names(string input)
{
Assert.False(ChatTimeZones.TryParse(input, out _, out var error));
Assert.Equal($"Unknown time zone \"{input}\". Use an IANA name such as Europe/Berlin or America/New_York.", error);
}
}

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.Configuration;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Speech;
@@ -160,4 +161,37 @@ public sealed class FormattingAndOptionsTests
options.Speech.BaseUrl = url;
Assert.Contains("Telegram:Speech:BaseUrl must be an absolute http(s) URL.", options.Validate());
}
[Fact]
public void DefaultTimeZone_comes_from_the_environment_and_unknown_ids_fail_validation()
{
var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] };
options.ApplyEnvironment(name => name == "DEFAULT_TIMEZONE" ? " Asia/Tokyo " : null);
Assert.Equal("Asia/Tokyo", options.DefaultTimeZone);
Assert.Equal("Asia/Tokyo", options.ConfiguredTimeZone()!.Id);
Assert.Empty(options.Validate());
var empty = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] };
empty.ApplyEnvironment(name => name == "DEFAULT_TIMEZONE" ? "" : null);
Assert.Null(empty.ConfiguredTimeZone()); // empty = unset: the machine's zone
var bad = new TelegramOptions { BotToken = "t", AllowedUserIds = [1], DefaultTimeZone = "Mars/Olympus" };
Assert.Contains(bad.Validate(), e => e.StartsWith("Telegram:DefaultTimeZone: Unknown time zone \"Mars/Olympus\"", StringComparison.Ordinal));
}
[Fact]
public void Scheduler_options_default_and_validate()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Scheduler:MaxJobsPerTarget"] = "5" }).Build();
var options = Volcanic.AgenticCore.Telegram.Scheduling.SchedulerSetup.Options(config.GetSection("Scheduler"), "/agent");
Assert.True(options.Enabled);
Assert.Equal(Path.Combine("/agent", "scheduler", "jobs.json"), options.StorePath);
Assert.Equal(TimeSpan.FromSeconds(300), options.DefaultScriptTimeout);
Assert.Equal(5, options.MaxJobsPerTarget);
Assert.Empty(options.Validate());
options.MaxJobsPerTarget = 0;
Assert.Contains(options.Validate(), e => e.Contains("MaxJobsPerTarget", StringComparison.Ordinal));
}
}

View File

@@ -56,7 +56,7 @@ public sealed class MessageContextChatTests
await FlushAsync(host, time, parts: 2);
await host.SettleAsync();
// The zone comes from the ChatRegistry.TimeZoneFor seam (the session's zone until the scheduler's ChatSettingsStore).
// The zone comes from the ChatRegistry.TimeZoneFor seam (the chat's zone from ChatSettingsStore, which defaults to AgenticCoreOptions.DefaultTimeZone).
Assert.Equal("Asia/Tokyo", Volcanic.AgenticCore.Hosting.TimeZoneIds.ToIana(host.Chats.TimeZoneFor(Updates.AllowedUser)));
var lines = Prompt(host).Split('\n');
Assert.Equal(MessageContext.TimestampHeader(SentAt(comment), Tokyo), lines[0]);
@@ -64,6 +64,24 @@ public sealed class MessageContextChatTests
Assert.Single(lines, l => TimestampLine.IsMatch(l)); // one per batch, not per part
}
[Fact]
public async Task After_timezone_Asia_Tokyo_the_next_messages_header_says_Tokyo()
{
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
await using var host = new ChatHost(core: o => o.DefaultTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"), time: time);
host.Faux.Enqueue(FauxTurn.Say("It's evening in Tokyo."));
await host.SendAsync("/timezone Asia/Tokyo"); // a bot command: handled at once, no inbox window
var message = Updates.Text("what time is it?");
await host.RouteAsync(message);
await FlushAsync(host, time, parts: 1);
await host.SettleAsync();
var first = Prompt(host).Split('\n')[0];
Assert.Equal(MessageContext.TimestampHeader(SentAt(message), Tokyo), first);
Assert.EndsWith(" Asia/Tokyo]", first);
}
[Fact]
public async Task Plain_text_waits_for_the_window_and_a_forward_inside_it_joins_the_request()
{

View File

@@ -0,0 +1,203 @@
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Tests.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests;
/// <summary>Scheduled prompts and watcher matches reach the agent; everything else stays out of the model and the transcript.</summary>
public sealed class ScheduledPromptChatTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private static readonly DateTimeOffset Now = SchedulerChatTests.Now; // Sat 26 Sep 14:03 Berlin
private const long OtherUser = 2002;
/// <summary>"yyyy-MM-ddTHH:mm" in Berlin, <paramref name="minutes"/> after the fake clock's now.</summary>
private static string BerlinAt(FakeTimeProvider time, int minutes) =>
TimeZoneInfo.ConvertTime(time.GetUtcNow().AddMinutes(minutes), Berlin).ToString("yyyy-MM-ddTHH:mm", System.Globalization.CultureInfo.InvariantCulture);
[Fact]
public async Task A_prompt_job_on_an_idle_chat_renders_the_reply_with_the_timestamp_header()
{
var (host, time) = await SchedulerChatTests.StartedAsync();
await using var _ = host;
var job = (await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Inbox summary", JobKind.Prompt, At: BerlinAt(time, 2), Text: "Summarise inbox/"), CancellationToken.None)).Job;
host.Faux.Enqueue(FauxTurn.Say("Your inbox is empty."));
await SchedulerChatTests.AdvanceAsync(host, time, TimeSpan.FromMinutes(2));
await host.SettleAsync();
Assert.Contains(host.Texts, t => t.Contains("Your inbox is empty.", StringComparison.Ordinal));
Assert.Equal(
$"{MessageContext.TimestampHeader(Now.AddMinutes(2), Berlin)}\n[Scheduled job \"Inbox summary\" ({job.Id})]\nSummarise inbox/",
Assert.Single(host.Faux.Requests).Messages.Last().Text);
}
[Fact]
public async Task A_prompt_job_during_a_user_run_is_queued_as_a_follow_up_and_answered_after_the_user()
{
var started = new TaskCompletionSource();
var gate = new TaskCompletionSource();
var time = new FakeTimeProvider(Now);
await using var host = new ChatHost(core: o => { o.DefaultTimeZone = Berlin; o.AddExtension(new GateToolExtension(started, gate)); },
time: time, scheduler: new SchedulerOptions());
await host.StartSchedulerAsync();
host.Faux.Enqueue(FauxTurn.Tool(GateToolExtension.ToolName, new { }), FauxTurn.Say("User answer."), FauxTurn.Say("Job answer."));
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Check", JobKind.Prompt, At: BerlinAt(time, 1), Text: "Check the build"), CancellationToken.None);
var chat = await host.ChatAsync();
var queues = new List<AgentSessionEvent.QueueUpdate>();
using var subscription = chat.Session.Subscribe(e => { if (e is AgentSessionEvent.QueueUpdate q) lock (queues) queues.Add(q); });
await host.SendAsync("run the long thing");
await MessageContextChatTests.FlushAsync(host, time, parts: 1);
await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
time.Advance(TimeSpan.FromMinutes(1)); // the job fires while the user's run is active
await Wait.UntilAsync(() => { lock (queues) return queues.Any(q => q.FollowUp.Count == 1 && q.Steering.Count == 0); }, "the job's follow-up");
gate.SetResult();
await host.Scheduler.IdleAsync();
await host.SettleAsync();
var texts = host.Texts.ToList();
var user = texts.FindIndex(t => t.Contains("User answer.", StringComparison.Ordinal));
var job = texts.FindIndex(t => t.Contains("Job answer.", StringComparison.Ordinal));
Assert.True(user >= 0 && job > user, string.Join(" | ", texts));
lock (queues) Assert.DoesNotContain(queues, q => q.Steering.Count > 0);
}
[Fact]
public async Task A_watcher_over_a_real_script_stays_silent_until_it_prints()
{
if (OperatingSystem.IsWindows()) return;
var time = new FakeTimeProvider(Now);
await using var host = new ChatHost(core: o => o.DefaultTimeZone = Berlin, time: time, scheduler: new SchedulerOptions()); // real ScriptRunner
await host.StartSchedulerAsync();
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Price", JobKind.Watcher, Cron: "* * * * *", Command: "cat match.txt 2>/dev/null; exit 0"), CancellationToken.None);
for (var i = 0; i < 3; i++) await SchedulerChatTests.AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
Assert.Empty(host.Faux.Requests);
Assert.Null(host.Chats.Find(Updates.AllowedUser));
Assert.Empty(host.Bot.Sent);
await File.WriteAllTextAsync(Path.Combine(host.WorkspaceOf(), "match.txt"), "price 48\n");
host.Faux.Enqueue(FauxTurn.Say("The price dropped to 48."));
await SchedulerChatTests.AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
await host.SettleAsync();
var request = Assert.Single(host.Faux.Requests);
Assert.EndsWith("The watcher script printed:\nprice 48", request.Messages.Last().Text);
Assert.Contains(host.Texts, t => t.Contains("The price dropped to 48.", StringComparison.Ordinal));
}
[Fact]
public async Task A_schedule_tool_call_creates_a_job_for_its_own_chat_only()
{
var (host, time) = await SchedulerChatTests.StartedAsync();
await using var _ = host;
host.Faux.Enqueue(
FauxTurn.Tool("schedule", new { action = "create", name = "Stretch", kind = "reminder", cron = "0 * * * *", text = "Stretch!" }),
FauxTurn.Say("Scheduled."));
var chat = await host.ChatAsync();
await chat.Session.PromptAsync("remind me to stretch every hour");
await host.SendAsync("/jobs", userId: OtherUser);
Assert.Equal("Stretch", Assert.Single(host.Scheduler!.List(ChatHost.Target)).Job.Name);
Assert.Equal("No scheduled jobs.", host.LastReply(OtherUser));
await host.SendAsync("/jobs");
Assert.Contains("⏰ Stretch · 0 * * * * · next 15:00", host.LastReply());
}
[Fact]
public async Task Restart_delivers_an_overdue_one_off_late_and_does_not_catch_up_cron()
{
var root = Directory.CreateTempSubdirectory("scheduler-restart-").FullName;
try
{
var before = new FakeTimeProvider(Now);
await using (var first = new ChatHost(core: o => o.DefaultTimeZone = Berlin, time: before, root: root, scheduler: new SchedulerOptions()))
{
await first.StartSchedulerAsync();
await first.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Dentist", JobKind.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);
}
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 second.StartSchedulerAsync();
await second.Scheduler!.IdleAsync();
await (await second.ChatAsync()).Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal("⏰ Call the dentist (due 26 Sep 15:03, delivered late)", Assert.Single(second.Bot.Sent).Text);
var hourly = Assert.Single(second.Scheduler.List(ChatHost.Target));
Assert.Equal(new DateTimeOffset(2026, 9, 26, 16, 0, 0, TimeSpan.Zero), hourly.NextRunAt); // 18:00 Berlin, not caught up
}
finally { Directory.Delete(root, recursive: true); }
}
[Fact]
public async Task A_scheduled_prompt_does_not_answer_a_pending_login_code_prompt()
{
var (host, time) = await SchedulerChatTests.StartedAsync();
await using var _ = host;
var ui = await new OwnerLoginChannel(host.Chats, host.Options).ResolveAsync(CancellationToken.None);
var ask = new UiLoginPrompt(ui!).AskForCodeAsync("https://claude.com/cai/oauth/authorize?state=t", 1, CancellationToken.None);
var chat = await host.ChatAsync();
await Wait.UntilAsync(() => chat.Ui.IsWaitingForInput, "the login code question");
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Check", JobKind.Prompt, At: BerlinAt(time, 1), Text: "Check the build"), CancellationToken.None);
host.Faux.Enqueue(FauxTurn.Say("Build is green."));
await SchedulerChatTests.AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
await host.SettleAsync();
Assert.False(ask.IsCompleted);
Assert.True(chat.Ui.IsWaitingForInput);
Assert.Contains("[Scheduled job \"Check\"", Assert.Single(host.Faux.Requests).Messages.Last().Text);
await host.SendAsync("pasted-code");
Assert.Equal("pasted-code", await ask.WaitAsync(TimeSpan.FromSeconds(5)));
}
[Fact]
public async Task A_scheduled_prompt_that_fails_is_reported_to_the_chat_and_the_job_still_completes()
{
var (host, time) = await SchedulerChatTests.StartedAsync();
await using var _ = host;
var chat = await host.ChatAsync();
await chat.Session.DisposeAsync(); // PromptAsync now throws, as any failure to start the run would
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Check", JobKind.Prompt, At: BerlinAt(time, 1), Text: "Check the build"), CancellationToken.None);
await SchedulerChatTests.AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
Assert.Contains(host.Texts, t => t.Contains("The scheduled job failed:", StringComparison.Ordinal));
Assert.Empty(host.Faux.Requests);
Assert.Empty(host.Scheduler.List(ChatHost.Target)); // the one-off completed (no redelivery queue)
}
[Fact]
public async Task Timezone_with_jobs_reports_counts_and_jobs_shows_tokyo_times()
{
var (host, _) = await SchedulerChatTests.StartedAsync();
await using var __ = host;
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Morning", JobKind.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.SendAsync("/timezone Asia/Tokyo");
Assert.Equal("Time zone set to Asia/Tokyo (UTC+09:00). Now 26 Sep 21:03. 1 recurring job now follows this zone; 1 one-off job keeps its time (now 27 Sep 16:00 here).",
host.LastReply());
Assert.Equal("Asia/Tokyo", host.Settings.TimeZoneFor(Updates.AllowedUser).Id);
await host.SendAsync("/jobs");
var jobs = host.LastReply();
Assert.StartsWith("Scheduled jobs (Asia/Tokyo, now 26 Sep 21:03)", jobs);
Assert.Contains("Morning · 0 9 * * * · next 27 Sep 09:00", jobs);
Assert.Contains("Dentist · once 27 Sep 16:00", jobs);
}
}

View File

@@ -0,0 +1,176 @@
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Telegram.Scheduling;
using Volcanic.AgenticCore.Telegram.Tests.Support;
namespace Volcanic.AgenticCore.Telegram.Tests;
/// <summary>The scheduler through the Telegram delivery and commands, on a fake clock with a fake script runner.</summary>
public sealed class SchedulerChatTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
internal static readonly DateTimeOffset Now = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero); // Sat 14:03 Berlin
private const long OtherUser = 2002;
internal sealed class Runner : IScriptRunner
{
public int Calls;
public ScriptOutcome Outcome { get; set; } = new ScriptOutcome.Silent();
public Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref Calls);
return Task.FromResult(Outcome);
}
}
internal static async Task<(ChatHost Host, FakeTimeProvider Time)> StartedAsync(IScriptRunner? runner = null, Action<Microsoft.Extensions.DependencyInjection.IServiceCollection>? services = null)
{
var time = new FakeTimeProvider(Now);
var host = new ChatHost(core: o => o.DefaultTimeZone = Berlin, time: time, scheduler: new SchedulerOptions(), runner: runner);
host.Options.AllowedUserIds.Add(OtherUser);
await host.StartSchedulerAsync();
return (host, time);
}
internal static async Task AdvanceAsync(ChatHost host, FakeTimeProvider time, TimeSpan by)
{
time.Advance(by);
await host.Scheduler!.IdleAsync();
}
[Fact]
public async Task A_reminder_is_sent_to_its_chat_without_a_model_call_or_transcript_entry()
{
var (host, time) = await StartedAsync();
await using var _ = host;
var chat = await host.ChatAsync();
var entriesBefore = chat.Session.SessionManager.GetEntries().Count;
var leafBefore = chat.Session.SessionManager.LeafId;
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Dentist", JobKind.Reminder, At: "2026-09-26T14:05", Text: "Call the **dentist**"), CancellationToken.None);
await AdvanceAsync(host, time, TimeSpan.FromMinutes(2));
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
var sent = Assert.Single(host.Bot.Sent);
Assert.Equal(Updates.AllowedUser, sent.ChatId.Identifier);
Assert.Equal("⏰ Call the <b>dentist</b>", sent.Text);
Assert.Empty(host.Faux.Requests);
Assert.Empty(chat.Session.Messages);
Assert.Equal(entriesBefore, chat.Session.SessionManager.GetEntries().Count); // no entry of any kind
Assert.Equal(leafBefore, chat.Session.SessionManager.LeafId);
}
[Fact]
public async Task A_silent_watcher_with_a_fake_runner_creates_no_session_and_sends_nothing()
{
var runner = new Runner();
var (host, time) = await StartedAsync(runner);
await using var _ = host;
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Price", JobKind.Watcher, Cron: "* * * * *", Command: "scripts/price.sh"), CancellationToken.None);
for (var i = 0; i < 5; i++) await AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
Assert.Equal(5, runner.Calls);
Assert.Null(host.Chats.Find(Updates.AllowedUser)); // no session created or woken
Assert.Empty(host.Bot.Requests);
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task Delivery_WorkspaceFor_and_TimeZoneFor_never_create_a_session()
{
var (host, _) = await StartedAsync();
await using var __ = host;
await host.Settings.SetTimeZoneAsync(Updates.AllowedUser, "Asia/Tokyo", CancellationToken.None);
Assert.Equal(host.WorkspaceOf(), host.Delivery!.WorkspaceFor(ChatHost.Target));
Assert.Equal("Asia/Tokyo", host.Delivery.TimeZoneFor(ChatHost.Target).Id);
Assert.Same(host.Settings.DefaultZone, host.Delivery.TimeZoneFor("not-a-chat")); // a hand-edited store never throws here
Assert.Null(host.Chats.Find(Updates.AllowedUser));
}
[Fact]
public async Task IsDeliverable_follows_the_allow_list()
{
var (host, _) = await StartedAsync();
await using var __ = host;
Assert.True(host.Delivery!.IsDeliverable(ChatHost.Target));
Assert.False(host.Delivery.IsDeliverable(Updates.Stranger.ToString(System.Globalization.CultureInfo.InvariantCulture)));
Assert.False(host.Delivery.IsDeliverable("not-a-chat"));
}
[Fact]
public async Task Jobs_lists_this_chats_jobs_in_its_zone()
{
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 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",
host.LastReply());
}
[Fact]
public async Task Jobs_without_jobs_says_so()
{
var (host, _) = await StartedAsync();
await using var __ = host;
await host.SendAsync("/jobs");
Assert.Equal("No scheduled jobs.", host.LastReply());
}
[Fact]
public async Task Cancel_handles_own_unknown_and_other_chats_ids()
{
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;
await host.SendAsync($"/cancel {theirs}");
Assert.Equal($"No job {theirs} in this chat.", host.LastReply());
await host.SendAsync("/cancel zzzzz");
Assert.Equal("No job zzzzz in this chat.", host.LastReply());
await host.SendAsync($"/cancel {mine.ToUpperInvariant()}");
Assert.Equal($"Cancelled {mine} (Call the dentist).", host.LastReply());
Assert.Empty(host.Scheduler.List(ChatHost.Target));
Assert.Single(host.Scheduler.List("2002"));
}
[Fact]
public async Task Cancel_without_an_id_shows_usage()
{
var (host, _) = await StartedAsync();
await using var __ = host;
await host.SendAsync("/cancel");
Assert.Equal("Usage: /cancel <id> (see /jobs).", host.LastReply());
}
[Fact]
public async Task Status_shows_the_job_count()
{
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.SendAsync("/status");
Assert.Contains("Jobs: 1\n", host.LastReply());
}
[Fact]
public async Task Without_a_scheduler_jobs_says_scheduling_is_off()
{
await using var host = new ChatHost();
await host.SendAsync("/jobs");
Assert.Equal(SchedulerSetup.DisabledReply, host.LastReply());
await host.SendAsync("/cancel abcde");
Assert.Equal(SchedulerSetup.DisabledReply, host.LastReply());
await host.SendAsync("/status");
Assert.DoesNotContain("Jobs:", host.LastReply());
Assert.Contains("Time zone:", host.LastReply());
}
}

View File

@@ -2,6 +2,8 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Features.Sessions;
@@ -9,6 +11,7 @@ using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Scheduling;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
@@ -27,12 +30,34 @@ public sealed class ChatHost : IAsyncDisposable
public UpdateRouter Router { get; }
private readonly ServiceProvider _services;
private readonly string _root;
private readonly bool _ownsRoot;
/// <summary>Per-chat settings (<c>&lt;root&gt;/agent/telegram/chat-settings.json</c>); the default zone is the core option.</summary>
public ChatSettingsStore Settings { get; }
public ChatTimeZoneService Zones { get; }
/// <summary>The host's root directory; a caller-supplied root survives disposal (e.g. for a restart).</summary>
public string Root => _root;
/// <summary>The scheduler when the host was built with <c>scheduler:</c> options; start it with <see cref="StartSchedulerAsync"/>.</summary>
public JobScheduler? Scheduler { get; }
public TelegramJobDelivery? Delivery { get; }
/// <summary><see cref="Updates.AllowedUser"/> as a scheduler target.</summary>
public const string Target = "1001";
public Task StartSchedulerAsync() => Scheduler!.StartAsync(Delivery!, CancellationToken.None);
public ChatHost(Action<ChatHost, IServiceCollection>? configure = null, Action<AgenticCoreOptions>? core = null, TimeProvider? time = null,
Volcanic.AgenticCore.Extensions.GoogleCalendar.GoogleCalendarOptions? calendar = null,
Volcanic.AgenticCore.Telegram.Speech.ISpeechToText? speech = null)
Volcanic.AgenticCore.Telegram.Speech.ISpeechToText? speech = null, string? root = null, SchedulerOptions? scheduler = null,
IScriptRunner? runner = null)
{
_root = Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N"));
_ownsRoot = root is null;
_root = root ?? Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N"));
var agentDir = Path.Combine(_root, "agent");
Options = new TelegramOptions
{
BotToken = "test",
@@ -45,10 +70,11 @@ public sealed class ChatHost : IAsyncDisposable
};
var store = new InMemorySessionStore();
var services = new ServiceCollection();
if (time is not null) services.AddSingleton(time); // sessions render the prompt date from it
configure?.Invoke(this, services);
services.AddAgenticCore(o =>
{
o.AgentDir = Path.Combine(_root, "agent");
o.AgentDir = agentDir;
o.DiscoverExtensionAssemblies = false;
o.DefaultModel = FauxProvider.ModelKey;
o.Extensions.Add(ExtensionRegistration.FromInstance(Faux.Extension));
@@ -59,10 +85,22 @@ public sealed class ChatHost : IAsyncDisposable
_services = services.BuildServiceProvider();
ILoggerFactory loggers = NullLoggerFactory.Instance;
var sender = new TelegramSender(Bot, NullLogger<TelegramSender>.Instance);
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar);
var clock = time ?? TimeProvider.System;
var coreOptions = _services.GetRequiredService<Microsoft.Extensions.Options.IOptions<AgenticCoreOptions>>().Value;
Settings = new ChatSettingsStore(ChatSettingsStore.DefaultPath(agentDir), coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local, NullLogger<ChatSettingsStore>.Instance, clock);
if (scheduler is not null)
{
if (string.IsNullOrWhiteSpace(scheduler.StorePath)) scheduler.StorePath = SchedulerOptions.DefaultStorePath(agentDir);
Scheduler = new JobScheduler(scheduler,
runner ?? new ScriptRunner(scheduler, _services.GetRequiredService<IProcessSpawnPolicy>(), NullLogger<ScriptRunner>.Instance, clock),
_services.GetRequiredService<ISecretRedactor>(), clock, NullLogger<JobScheduler>.Instance);
}
Zones = new ChatTimeZoneService(Settings, id => Chats!.Find(id)?.Session, clock, Scheduler);
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar, Settings, Zones, Scheduler);
if (Scheduler is not null) Delivery = new TelegramJobDelivery(Chats, Settings, Options, clock, NullLogger<TelegramJobDelivery>.Instance);
var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger<InboundMedia>.Instance, speech);
Composer = new InboundComposer(sender, media, NullLogger<InboundComposer>.Instance);
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender, Composer, NullLogger<UpdateRouter>.Instance);
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender, Zones, Scheduler), sender, Composer, NullLogger<UpdateRouter>.Instance);
}
/// <summary>The composer the router uses (content steps 1–3).</summary>
@@ -88,12 +126,19 @@ public sealed class ChatHost : IAsyncDisposable
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
}
/// <summary>The last message sent to <paramref name="chatId"/>, as plain text (tags stripped, entities decoded).</summary>
public string LastReply(long chatId = Updates.AllowedUser) =>
System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(
Bot.Sent.Last(s => s.ChatId.Identifier == chatId).Text, "<[^>]+>", ""));
public IEnumerable<string> Texts => Bot.CurrentTexts.OrderBy(kv => kv.Key).Select(kv => kv.Value);
public async ValueTask DisposeAsync()
{
if (Scheduler is not null) await Scheduler.StopAsync(CancellationToken.None);
await Chats.DisposeAsync();
await _services.DisposeAsync();
try { Directory.Delete(_root, true); } catch (IOException) { }
if (_ownsRoot)
try { Directory.Delete(_root, true); } catch (IOException) { }
}
}

View File

@@ -0,0 +1,135 @@
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Telegram.Tests.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests;
/// <summary>Per-chat time zones through <c>/timezone</c>, the <c>set_timezone</c> tool, a restart and <c>/status</c>.</summary>
public sealed class TimeZoneChatTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private static readonly DateTimeOffset Now = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero); // 14:03 Berlin, 21:03 Tokyo
private const long OtherUser = 2002;
private static ChatHost Host(string? root = null) =>
new(core: o => o.DefaultTimeZone = Berlin, time: new FakeTimeProvider(Now), root: root);
[Fact]
public async Task Timezone_sets_the_zone_the_session_and_the_next_system_prompt()
{
await using var host = Host();
await host.SendAsync("/timezone Asia/Tokyo");
Assert.Equal("Time zone set to Asia/Tokyo (UTC+09:00). Now 26 Sep 21:03.", host.LastReply());
Assert.Equal("Asia/Tokyo", host.Settings.TimeZoneFor(Updates.AllowedUser).Id);
var chat = await host.ChatAsync();
Assert.Equal("Asia/Tokyo", chat.Session.TimeZone.Id);
host.Faux.Enqueue(FauxTurn.Say("ok"));
await chat.Session.PromptAsync("hi"); // straight to the session: the inbox window runs on the fake clock
Assert.Contains("Time zone: Asia/Tokyo (UTC+09:00)", host.Faux.Requests.Last().SystemPrompt);
Assert.Contains("Current date: 2026-09-26 (Saturday)", host.Faux.Requests.Last().SystemPrompt);
}
[Fact]
public async Task Unknown_zone_replies_with_the_error_and_stores_nothing()
{
await using var host = Host();
await host.SendAsync("/timezone Mars/Olympus");
Assert.Equal("Unknown time zone \"Mars/Olympus\". Use an IANA name such as Europe/Berlin or America/New_York.", host.LastReply());
Assert.False(host.Settings.HasOwnZone(Updates.AllowedUser));
Assert.False(File.Exists(Volcanic.AgenticCore.Telegram.Chats.ChatSettingsStore.DefaultPath(Path.Combine(host.Root, "agent"))));
}
[Fact]
public async Task Reset_returns_to_the_default()
{
await using var host = Host();
await host.SendAsync("/timezone Asia/Tokyo");
await host.SendAsync("/timezone reset");
Assert.Equal("Time zone reset to the default, Europe/Berlin (UTC+02:00). Now 26 Sep 14:03.", host.LastReply());
Assert.False(host.Settings.HasOwnZone(Updates.AllowedUser));
Assert.Equal("Europe/Berlin", (await host.ChatAsync()).Session.TimeZone.Id);
}
[Fact]
public async Task Timezone_alone_shows_the_zone_and_whether_it_is_the_default()
{
await using var host = Host();
await host.SendAsync("/timezone");
Assert.Equal("Time zone: Europe/Berlin (UTC+02:00, default). Change it with /timezone <IANA name>, e.g. /timezone America/New_York.", host.LastReply());
await host.SendAsync("/timezone America/New_York");
await host.SendAsync("/timezone");
Assert.StartsWith("Time zone: America/New_York (UTC-04:00). Change it", host.LastReply());
}
[Fact]
public async Task A_restart_keeps_the_zone()
{
var root = Directory.CreateTempSubdirectory("tz-restart-").FullName;
try
{
await using (var first = Host(root)) await first.SendAsync("/timezone Asia/Tokyo");
await using var second = Host(root);
Assert.Equal("Asia/Tokyo", (await second.ChatAsync()).Session.TimeZone.Id);
}
finally
{
try { Directory.Delete(root, recursive: true); } catch (IOException) { }
}
}
[Fact]
public async Task A_scripted_set_timezone_call_does_the_same_as_the_command()
{
await using var host = Host();
host.Faux.Enqueue(FauxTurn.Tool("set_timezone", new { zone = "Asia/Tokyo" }), FauxTurn.Say("Done: Tokyo time."));
var chat = await host.ChatAsync();
await chat.Session.PromptAsync("I moved to Tokyo");
Assert.Equal("Asia/Tokyo", host.Settings.TimeZoneFor(Updates.AllowedUser).Id);
Assert.Equal("Asia/Tokyo", chat.Session.TimeZone.Id);
var result = chat.Session.Messages.OfType<ToolResultMessage>().Single();
Assert.False(result.IsError);
Assert.StartsWith("Time zone set to Asia/Tokyo (UTC+09:00).", result.Text);
}
[Fact]
public async Task A_scripted_set_timezone_with_an_unknown_zone_is_an_error_result()
{
await using var host = Host();
host.Faux.Enqueue(FauxTurn.Tool("set_timezone", new { zone = "Mars/Olympus" }), FauxTurn.Say("Sorry."));
var chat = await host.ChatAsync();
await chat.Session.PromptAsync("I live on Mars");
var result = chat.Session.Messages.OfType<ToolResultMessage>().Single();
Assert.True(result.IsError);
Assert.StartsWith("Unknown time zone \"Mars/Olympus\"", result.Text);
Assert.False(host.Settings.HasOwnZone(Updates.AllowedUser));
}
[Fact]
public async Task Another_chats_zone_is_untouched()
{
await using var host = Host();
host.Options.AllowedUserIds.Add(OtherUser);
await host.SendAsync("/timezone Asia/Tokyo");
await host.SendAsync("/timezone", userId: OtherUser);
Assert.Equal("Europe/Berlin", host.Settings.TimeZoneFor(OtherUser).Id);
Assert.StartsWith("Time zone: Europe/Berlin (UTC+02:00, default).", host.LastReply(OtherUser));
}
[Fact]
public async Task Status_shows_the_time_zone()
{
await using var host = Host();
await host.SendAsync("/timezone Asia/Tokyo");
await host.SendAsync("/status");
Assert.Contains("Time zone: Asia/Tokyo (UTC+09:00)", host.LastReply());
}
}

View File

@@ -142,6 +142,29 @@ public class AgentLoopTests
Assert.Equal(["start", "steer me", "later"], session.Messages.OfType<UserMessage>().Select(m => m.Text));
}
[Fact]
public async Task A_follow_up_prompt_that_loses_the_race_to_a_run_is_queued_as_a_follow_up_not_steering()
{
var gate = new TaskCompletionSource();
await using var host = new TestHost(null, WithTools(Echo("wait", async v => { await gate.Task; return v; })));
host.Faux.Enqueue(FauxTurn.Tool("wait", new { value = "x" }), FauxTurn.Say("after tool"), FauxTurn.Say("after scheduled"));
await using var session = await host.CreateSessionAsync();
var run = session.PromptAsync("user");
await WaitUntil(() => session.IsStreaming && host.Events.OfType<AgentSessionEvent.ToolExecutionStart>().Any());
// What PromptAsync(FollowUp) does when it saw an idle session just before the user's run took the run gate.
var scheduled = session.StartRunAsync(UserMessage.From("scheduled", null), null, CancellationToken.None, StreamingBehavior.FollowUp);
await WaitUntil(() => host.Events.OfType<AgentSessionEvent.QueueUpdate>().Any());
Assert.Contains(host.Events, e => e is AgentSessionEvent.QueueUpdate { Steering.Count: 0, FollowUp.Count: 1 });
gate.SetResult();
await run;
await scheduled;
Assert.Equal(3, host.Faux.Requests.Count);
Assert.IsType<ToolResultMessage>(host.Faux.Requests[1].Messages[^1]); // not steered into the user's turn
Assert.Equal("scheduled", host.Faux.Requests[2].Messages[^1].Text);
}
[Fact]
public async Task Prompt_while_streaming_without_behavior_throws()
{

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
@@ -28,7 +29,7 @@ public class SystemPromptBuilderTests
new DelegateTool<object>("alpha", "d", (_, _, _) => Task.FromResult(ToolResult.Text("")), promptSnippet: "alpha: does a", promptGuidelines: ["Use alpha wisely"]),
new DelegateTool<object>("silent", "d", (_, _, _) => Task.FromResult(ToolResult.Text(""))),
};
var builder = new SystemPromptBuilder(sub, () => tools, () => new DateTimeOffset(2026, 9, 25, 0, 0, 0, TimeSpan.Zero));
var builder = new SystemPromptBuilder(sub, () => tools, new FakeTimeProvider(new DateTimeOffset(2026, 9, 25, 0, 0, 0, TimeSpan.Zero)), () => TimeZoneInfo.Utc);
var options = builder.DefaultOptions("BASE");
options.Sections["extra"] = "more";
var prompt = builder.Render(options);
@@ -41,7 +42,7 @@ public class SystemPromptBuilderTests
var ctxStart = prompt.IndexOf("<project_context>", StringComparison.Ordinal);
Assert.True(prompt.IndexOf("Sub rules", StringComparison.Ordinal) < prompt.IndexOf("Root rules", StringComparison.Ordinal));
Assert.True(ctxStart > 0);
Assert.Contains("Current date: 2026-09-25", prompt);
Assert.Contains("Current date: 2026-09-25 (Friday)\nTime zone: ", prompt);
Assert.Contains($"Working directory: {sub}", prompt);
options.SelectedTools.Clear();
@@ -66,7 +67,7 @@ public class SystemPromptBuilderTests
File.WriteAllText(Path.Combine(dir, "memory.md"), "- User prefers tabs");
try
{
var builder = new SystemPromptBuilder(dir, () => [], () => DateTimeOffset.UnixEpoch);
var builder = new SystemPromptBuilder(dir, () => [], new FakeTimeProvider(DateTimeOffset.UnixEpoch), () => TimeZoneInfo.Utc);
var prompt = builder.Render(builder.DefaultOptions("BASE"));
Assert.Contains("Agent rules", prompt);

View File

@@ -1,5 +1,7 @@
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Testing;
@@ -76,4 +78,72 @@ public class SessionTimeZoneTests
Assert.Equal("Europe/Berlin", TimeZoneIds.ToIana(Berlin));
Assert.Equal("Europe/Berlin", TimeZoneIds.ToIana(TimeZoneInfo.FindSystemTimeZoneById("W. Europe Standard Time")));
}
private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
private static readonly DateTimeOffset LateSaturdayUtc = new(2026, 9, 26, 23, 30, 0, TimeSpan.Zero);
[Theory]
[InlineData("Asia/Tokyo", "Current date: 2026-09-27 (Sunday)", "Time zone: Asia/Tokyo (UTC+09:00)")]
[InlineData("Europe/Berlin", "Current date: 2026-09-27 (Sunday)", "Time zone: Europe/Berlin (UTC+02:00)")]
[InlineData("America/New_York", "Current date: 2026-09-26 (Saturday)", "Time zone: America/New_York (UTC-04:00)")]
public async Task Prompt_shows_the_date_and_zone_in_the_session_zone(string zoneId, string dateLine, string zoneLine)
{
await using var host = new TestHost(new FakeTimeProvider(LateSaturdayUtc));
await using var session = await host.CreateSessionAsync(b => b.TimeZone = TimeZoneInfo.FindSystemTimeZoneById(zoneId));
var prompt = session.SystemPrompt;
Assert.Contains($"{dateLine}\n{zoneLine}\nWorking directory: ", prompt);
Assert.DoesNotMatch(@"\d{2}:\d{2}\n", prompt.Split("<environment>")[1]); // no time of day in the cached prefix
}
[Fact]
public async Task SetTimeZone_during_a_run_changes_the_next_runs_prompt_not_the_current_one()
{
AgentSession? session = null;
var switchZone = new DelegateTool<ProbeArgs>("switch_zone", "s", (_, _, _) =>
{
session!.SetTimeZone(Tokyo);
return Task.FromResult(ToolResult.Text("switched"));
});
await using var host = new TestHost(new FakeTimeProvider(LateSaturdayUtc), null,
new LambdaExtension("tools", api => { api.RegisterTool(switchZone); return ValueTask.CompletedTask; }));
host.Faux.Enqueue(FauxTurn.Tool("switch_zone", new { value = "x" }), FauxTurn.Say("done"), FauxTurn.Say("second"));
session = await host.CreateSessionAsync(b => b.TimeZone = Berlin);
await session.PromptAsync("first");
Assert.Equal("Asia/Tokyo", TimeZoneIds.ToIana(session.TimeZone)); // the property changes at once
await session.PromptAsync("again");
Assert.Equal(3, host.Faux.Requests.Count);
Assert.Contains("Time zone: Europe/Berlin", host.Faux.Requests[0].SystemPrompt);
Assert.Contains("Time zone: Europe/Berlin", host.Faux.Requests[1].SystemPrompt); // same run, same prompt
Assert.Contains("Time zone: Asia/Tokyo (UTC+09:00)", host.Faux.Requests[2].SystemPrompt);
await session.DisposeAsync();
}
[Fact]
public async Task SetTimeZone_changes_the_TZ_id_for_processes()
{
await using var host = new TestHost();
await using var session = await host.CreateSessionAsync();
session.SetTimeZone(Tokyo);
Assert.Equal("Asia/Tokyo", ((IExtensionHost)session).TimeZoneId);
}
[Fact]
public async Task Without_options_the_prompt_uses_the_local_zone()
{
await using var host = new TestHost();
await using var session = await host.CreateSessionAsync();
Assert.Contains($"Time zone: {TimeZoneIds.Name(TimeZoneInfo.Local)} (", session.SystemPrompt);
}
[Theory]
[InlineData(9, 0, "UTC+09:00")]
[InlineData(-4, 0, "UTC-04:00")]
[InlineData(5, 30, "UTC+05:30")]
[InlineData(0, 0, "UTC+00:00")]
public void FormatOffset_formats_sign_hours_and_minutes(int hours, int minutes, string expected) =>
Assert.Equal(expected, TimeZoneIds.FormatOffset(new TimeSpan(hours, hours < 0 ? -minutes : minutes, 0)));
}

View File

@@ -19,13 +19,21 @@ public sealed class TestHost : IAsyncDisposable
public string Cwd { get; }
public List<AgentSessionEvent> Events { get; } = [];
public TestHost(Action<AgenticCoreOptions>? configure = null, params IExtension[] extensions) : this(useFileStore: false, configure, extensions) { }
public TestHost(Action<AgenticCoreOptions>? configure = null, params IExtension[] extensions) : this(useFileStore: false, time: null, configure, extensions) { }
public TestHost(bool useFileStore, Action<AgenticCoreOptions>? configure = null, params IExtension[] extensions)
: this(useFileStore, time: null, configure, extensions) { }
/// <summary>A host whose sessions read <paramref name="time"/> (registered as the DI <see cref="TimeProvider"/>).</summary>
public TestHost(TimeProvider time, Action<AgenticCoreOptions>? configure = null, params IExtension[] extensions)
: this(useFileStore: false, time, configure, extensions) { }
private TestHost(bool useFileStore, TimeProvider? time, Action<AgenticCoreOptions>? configure, IExtension[] extensions)
{
Cwd = Path.Combine(Path.GetTempPath(), "volcanic-host", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Cwd);
var services = new ServiceCollection();
if (time is not null) services.AddSingleton(time);
services.AddAgenticCore(o =>
{
o.AgentDir = Path.Combine(Cwd, ".agent");

View File

@@ -10,6 +10,7 @@
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.9.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj" />