Files
EugeneTes 5b780fac36 feat(telegram): per-chat time zones with /timezone, set_timezone and Telegram:DefaultTimeZone
ChatSettingsStore keeps each chat's IANA zone in <AgentDir>/telegram/chat-settings.json;
ChatTimeZoneService is the one path for a change (store, live session, scheduler re-evaluation,
reply text). ChatRegistry.TimeZoneFor now reads the store and new sessions start in the chat's zone.
/timezone, the set_timezone tool (ChatSettingsExtension), /status zone line, DEFAULT_TIMEZONE.

The store updates its cache under the write gate so the last file written is the newest state.
2026-09-27 10:29:29 +02:00

136 lines
5.8 KiB
C#

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());
}
}