feat(core): session SetTimeZone and the date and zone in the system prompt

This commit is contained in:
EugeneTes
2026-09-27 09:58:26 +02:00
parent da60ddb536
commit 64e67b00d7
9 changed files with 136 additions and 13 deletions

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" />