feat(google-calendar): /calendar_connect device flow with automatic polling

This commit is contained in:
EugeneTes
2026-09-26 20:13:36 +02:00
parent c2048a822e
commit 59d404ed08
7 changed files with 686 additions and 11 deletions

View File

@@ -0,0 +1,34 @@
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>Replies through the UI, or as a custom message without a turn when there is none.</summary>
internal static class CommandReply
{
public static void Send(IExtensionApi api, IExtensionContext ctx, string customType, string text, NotifyKind kind = NotifyKind.Info)
{
if (ctx.HasUi) ctx.Ui.Notify(text, kind);
else api.SendMessage(CustomMessage.FromText(customType, text), MessageDelivery.Steer, triggerTurn: false);
}
}
/// <summary><c>/calendar_connect</c>: runs this chat's device flow to the end (the command already runs off the update loop).</summary>
internal static class ConnectCommand
{
public const string Name = "calendar_connect";
public const string Description = "Connect this chat's Google Calendar (link + code)";
public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, async (_, ctx, ct) =>
{
if (!ctx.HasUi)
{
CommandReply.Send(api, ctx, Name, $"/{Name} needs an interactive UI (a chat front end such as the Telegram bot).");
return;
}
var connection = extension.ConnectionFor(ctx.SessionManager.Scope);
var flow = await ConnectFlow.StartOrResendAsync(connection, ctx.Ui, ct).ConfigureAwait(false);
if (flow is not null) await flow.RunAsync(ctx.Ui, ctx.Logger).ConfigureAwait(false);
});
}

View File

@@ -0,0 +1,167 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>
/// One chat's device flow. It polls on its own cancellation source, not the command's token: <c>/stop</c> leaves it
/// alone, <c>/calendar_disconnect</c> cancels it silently, and it ends by itself at the code's expiry.
/// </summary>
internal sealed class ConnectFlow
{
private const int MaxTransportErrors = 3;
private static readonly TimeSpan SlowDownStep = TimeSpan.FromSeconds(5); // RFC 8628 §3.5
private readonly CancellationTokenSource _cancellation = new();
private readonly GoogleCalendarConnection _connection;
private int _parks;
private ConnectFlow(GoogleCalendarConnection connection, DeviceAuthorization device)
{
_connection = connection;
Device = device;
Interval = device.Interval;
}
public DeviceAuthorization Device { get; }
public TimeSpan Interval { get; private set; }
public CancellationToken Token => _cancellation.Token;
/// <summary>How many poll delays the flow has entered (tests drive the fake clock with it).</summary>
internal int Parks => Volatile.Read(ref _parks);
/// <summary>When the current delay ends.</summary>
internal DateTimeOffset NextPollAt { get; private set; }
public void Cancel()
{
try { _cancellation.Cancel(); } catch (ObjectDisposedException) { }
}
public static int MinutesLeft(DeviceAuthorization device, TimeProvider time) =>
Math.Max(1, (int)Math.Ceiling((device.ExpiresAt - time.GetUtcNow()).TotalMinutes));
/// <summary>Re-sends a pending flow's link and code, or starts a new flow and sends its link. Null when nothing more to do.</summary>
public static async Task<ConnectFlow?> StartOrResendAsync(GoogleCalendarConnection connection, IUserInterface ui, CancellationToken cancellationToken)
{
var time = connection.Settings.Time;
await connection.FlowGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (connection.Pending is { } pending)
{
var d = pending.Device;
ui.Notify($"A Google Calendar connection is already waiting for approval:\n1. Open {d.VerificationUrl}\n2. Enter the code {d.UserCode}\n({MinutesLeft(d, time)} min left.)");
return null;
}
DeviceAuthorization device;
try
{
device = await connection.OAuth.StartDeviceFlowAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is GoogleCalendarException or HttpRequestException || ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
ui.Notify($"Google Calendar connection failed: {ex.Message}", NotifyKind.Error);
return null;
}
var flow = new ConnectFlow(connection, device);
connection.Begin(flow);
ui.Notify($"Connect Google Calendar:\n1. Open {device.VerificationUrl}\n2. Enter the code {device.UserCode}\n3. Approve access. I'll confirm here when it's done (code valid for {MinutesLeft(device, time)} min).");
return flow;
}
finally
{
connection.FlowGate.Release();
}
}
public async Task RunAsync(IUserInterface ui, ILogger logger)
{
var time = _connection.Settings.Time;
var ct = _cancellation.Token;
var transportErrors = 0;
try
{
while (true)
{
NextPollAt = time.GetUtcNow() + Interval;
Interlocked.Increment(ref _parks);
await Task.Delay(Interval, time, ct).ConfigureAwait(false);
if (time.GetUtcNow() >= Device.ExpiresAt)
{
ui.Notify("The Google code expired. Run /calendar_connect again.", NotifyKind.Warning);
return;
}
PollResult result;
try
{
result = await _connection.OAuth.PollAsync(Device.DeviceCode, ct).ConfigureAwait(false);
transportErrors = 0;
}
catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException && !ct.IsCancellationRequested)
{
if (++transportErrors >= MaxTransportErrors)
{
ui.Notify($"Google Calendar connection failed: {ex.Message}", NotifyKind.Error);
return;
}
continue;
}
switch (result.Status)
{
case PollStatus.Pending:
continue;
case PollStatus.SlowDown:
Interval += SlowDownStep;
continue;
case PollStatus.Denied:
ui.Notify("Google Calendar access was denied.", NotifyKind.Warning);
return;
case PollStatus.Expired:
ui.Notify("The Google code expired. Run /calendar_connect again.", NotifyKind.Warning);
return;
case PollStatus.Failed:
ui.Notify($"Google Calendar connection failed: {result.Message}", NotifyKind.Error);
return;
default:
await CompleteAsync(result, ui, logger, ct).ConfigureAwait(false);
return;
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// cancelled by /calendar_disconnect or shutdown: silent
}
finally
{
_connection.End(this);
}
}
private async Task CompleteAsync(PollResult result, IUserInterface ui, ILogger logger, CancellationToken ct)
{
var primary = new PrimaryCalendar(null, null);
try
{
primary = await GoogleCalendarApi.GetPrimaryAsync(_connection.Settings.Http, result.AccessToken!, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is GoogleCalendarException or HttpRequestException or System.Text.Json.JsonException
|| ex is TaskCanceledException && !ct.IsCancellationRequested)
{
logger.LogWarning("google-calendar: looking up the primary calendar failed: {Error}", ex.Message);
}
try
{
_connection.Store.Write(new StoredToken(result.RefreshToken!, result.Scope, primary.Account, primary.TimeZone, _connection.Settings.Time.GetUtcNow()));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
ui.Notify($"Got a Google token but could not save it: {ex.Message}", NotifyKind.Error);
return;
}
_connection.Invalidate();
ui.Notify($"✅ Google Calendar connected as {primary.Account ?? "an unknown account"} (time zone {primary.TimeZone ?? "unknown"}).");
}
}

View File

@@ -111,14 +111,3 @@ internal sealed class GoogleCalendarConnection
public override string ToString() => $"CachedToken {{ ExpiresAt = {ExpiresAt:O} }}";
}
}
/// <summary>Placeholder until Task 7 moves this type to ConnectFlow.cs and gives it the polling loop.</summary>
internal sealed class ConnectFlow
{
private readonly CancellationTokenSource _cancellation = new();
public CancellationToken Token => _cancellation.Token;
public void Cancel()
{
try { _cancellation.Cancel(); } catch (ObjectDisposedException) { }
}
}

View File

@@ -0,0 +1,63 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>
/// Google Calendar for every chat: the <c>calendar</c> tool and <c>/calendar_connect</c>, <c>/calendar_status</c>,
/// <c>/calendar_disconnect</c>. Each chat (session scope) connects its own account. Inactive, with one log line,
/// until <c>GOOGLE_CLIENT_ID</c> and <c>GOOGLE_CLIENT_SECRET</c> are set. Instantiated per session.
/// </summary>
public sealed class GoogleCalendarExtension(GoogleCalendarOptions? options = null) : IExtension
{
public const string ExtensionName = "google-calendar";
private readonly GoogleCalendarOptions _options = options ?? new GoogleCalendarOptions();
private GoogleCalendarSettings? _settings;
private volatile string? _scope;
private volatile TimeZoneInfo? _chatZone;
public string Name => ExtensionName;
internal GoogleCalendarConnections Connections => _options.Connections ?? GoogleCalendarConnections.Shared;
internal GoogleCalendarConnection ConnectionFor(string scope) =>
Connections.For(_settings ?? throw new InvalidOperationException("google-calendar is not configured."), scope);
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(api);
var id = _options.EffectiveClientId;
var secret = _options.EffectiveClientSecret;
if (id is null || secret is null)
{
if (id is null && secret is null)
api.Logger.LogInformation("google-calendar disabled: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET");
else
api.Logger.LogWarning("google-calendar disabled: {Missing} is not set (both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are needed)",
id is null ? "GOOGLE_CLIENT_ID" : "GOOGLE_CLIENT_SECRET");
return ValueTask.CompletedTask;
}
_settings = _options.Resolve()!;
api.RegisterTool(new CalendarTool(_settings, Connections, CurrentZoneName));
api.RegisterCommand(ConnectCommand.Name, ConnectCommand.Create(api, this));
api.On<SessionStartEvent>((_, ctx, _) => Track(ctx));
api.On<BeforeAgentStartEvent>((_, ctx, _) => Track(ctx));
return ValueTask.CompletedTask;
}
/// <summary>The zone the tool's description and guideline name: the calendar's, else the chat's. Null before the session starts.</summary>
internal string? CurrentZoneName() =>
_scope is { } scope && _settings is not null
? ZoneIds.Name(ConnectionFor(scope).ResolveTimeZone(_chatZone ?? TimeZoneInfo.Local))
: null;
private ValueTask Track(IExtensionContext ctx)
{
_chatZone = ctx.TimeZone;
_scope = ctx.SessionManager.Scope;
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,205 @@
using System.Net;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class ConnectFlowTests
{
private const string Connect = "calendar_connect";
[Fact]
public async Task The_link_and_code_arrive_in_one_notify()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
var command = h.Run(Connect, h.Context(ui));
await FlowDriver.ParkedAsync(h.Connection());
var (message, kind) = Assert.Single(ui.Notes);
Assert.Equal("Connect Google Calendar:\n1. Open https://www.google.com/device\n2. Enter the code ABCD-EFGH\n3. Approve access. I'll confirm here when it's done (code valid for 30 min).", message);
Assert.Equal(NotifyKind.Info, kind);
h.Connection().CancelPending();
await command.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Pending_pending_success_writes_the_store_and_notifies_once()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending, ConnectHarness.Pending, ConnectHarness.Approved());
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(3, h.Http.To(ConnectHarness.TokenRoute).Count);
Assert.Equal("Bearer at-1", Assert.Single(h.Http.To(ConnectHarness.PrimaryRoute)).Authorization);
var stored = h.Connection().Store.Read();
Assert.Equal(new StoredToken("rt-1", "https://www.googleapis.com/auth/calendar", "me@example.com", "Europe/Berlin", ConnectHarness.Now.AddSeconds(15)), stored);
Assert.Equal(2, ui.Notes.Count);
Assert.Equal(("✅ Google Calendar connected as me@example.com (time zone Europe/Berlin).", NotifyKind.Info), ui.Notes[1]);
Assert.Null(h.Connection().Pending);
}
[Fact]
public async Task Slow_down_widens_the_poll_interval_by_five_seconds()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending, ConnectHarness.SlowDown, ConnectHarness.Pending, ConnectHarness.Approved());
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(new CalendarRecordingUi())), h.Connection(), h.Time);
var polls = h.Http.To(ConnectHarness.TokenRoute).Select(r => r.At).ToList();
Assert.Equal(ConnectHarness.Now.AddSeconds(5), polls[0]);
Assert.Equal([TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)],
polls.Zip(polls.Skip(1), (a, b) => b - a).ToArray());
}
[Fact]
public async Task Denied_notifies_a_warning_and_writes_nothing()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, (HttpStatusCode.Forbidden, """{"error":"access_denied"}"""));
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(("Google Calendar access was denied.", NotifyKind.Warning), ui.Notes[^1]);
Assert.Null(h.Connection().Store.Read());
Assert.Null(h.Connection().Pending);
}
[Fact]
public async Task Expired_token_from_google_notifies_the_expiry_warning()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, (HttpStatusCode.BadRequest, """{"error":"expired_token"}"""));
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(("The Google code expired. Run /calendar_connect again.", NotifyKind.Warning), ui.Notes[^1]);
}
[Fact]
public async Task The_code_lifetime_ending_stops_polling_with_the_expiry_warning()
{
using var h = new ConnectHarness();
h.ScriptDevice("dev-1", "ABCD-EFGH", expiresIn: 12, interval: 5);
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(2, h.Http.To(ConnectHarness.TokenRoute).Count); // at +5 and +10; +15 is past +12
Assert.Equal(("The Google code expired. Run /calendar_connect again.", NotifyKind.Warning), ui.Notes[^1]);
}
[Fact]
public async Task Three_consecutive_transport_errors_fail_the_flow()
{
using var h = new ConnectHarness();
h.Http.Fail(ConnectHarness.TokenRoute, "network down");
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(3, h.Http.To(ConnectHarness.TokenRoute).Count);
Assert.Equal(("Google Calendar connection failed: network down", NotifyKind.Error), ui.Notes[^1]);
}
[Fact]
public async Task Two_transport_errors_then_approval_still_connects()
{
using var h = new ConnectHarness();
h.Http.On(ConnectHarness.TokenRoute, GoogleFakeHttp.Throws(), GoogleFakeHttp.Throws(),
GoogleFakeHttp.Returns(ConnectHarness.Approved().Item1, ConnectHarness.Approved().Item2));
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.StartsWith("✅ Google Calendar connected", ui.Notes[^1].Message);
}
[Fact]
public async Task A_failed_poll_is_reported_as_an_error()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, (HttpStatusCode.Unauthorized, """{"error":"invalid_client","error_description":"Unauthorized"}"""));
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(("Google Calendar connection failed: invalid_client: Unauthorized", NotifyKind.Error), ui.Notes[^1]);
}
[Fact]
public async Task A_failed_primary_lookup_still_saves_the_token()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Approved());
h.Http.Json(ConnectHarness.PrimaryRoute, (HttpStatusCode.InternalServerError, "{}"));
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
var stored = h.Connection().Store.Read()!;
Assert.Equal("rt-1", stored.RefreshToken);
Assert.Null(stored.Account);
Assert.Equal("✅ Google Calendar connected as an unknown account (time zone unknown).", ui.Notes[^1].Message);
}
[Fact]
public async Task A_store_write_failure_is_reported()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Approved());
Directory.CreateDirectory(h.Connection().Store.Path); // a directory where token.json should go
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.StartsWith("Got a Google token but could not save it: ", ui.Notes[^1].Message);
Assert.Equal(NotifyKind.Error, ui.Notes[^1].Kind);
}
[Fact]
public async Task A_second_connect_resends_the_same_code_without_a_new_device_code_request()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
var first = h.Run(Connect, h.Context(ui));
await FlowDriver.ParkedAsync(h.Connection());
h.Time.Advance(TimeSpan.FromSeconds(1));
await h.Run(Connect, h.Context(ui)).WaitAsync(TimeSpan.FromSeconds(5));
Assert.Single(h.Http.To(ConnectHarness.DeviceRoute));
Assert.Equal("A Google Calendar connection is already waiting for approval:\n1. Open https://www.google.com/device\n2. Enter the code ABCD-EFGH\n(30 min left.)", ui.Notes[1].Message);
h.Connection().CancelPending();
await first.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Cancelling_a_pending_flow_is_silent_and_clears_it()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
var command = h.Run(Connect, h.Context(ui));
await FlowDriver.ParkedAsync(h.Connection());
h.Connection().CancelPending();
await command.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Single(ui.Notes); // only the link
Assert.Null(h.Connection().Pending);
}
[Fact]
public async Task A_device_code_failure_is_reported_and_leaves_nothing_pending()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.DeviceRoute, (HttpStatusCode.Unauthorized, """{"error":"invalid_client"}"""));
var ui = new CalendarRecordingUi();
await h.Run(Connect, h.Context(ui)).WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(("Google Calendar connection failed: Google refused the device code request: invalid_client", NotifyKind.Error), Assert.Single(ui.Notes));
Assert.Null(h.Connection().Pending);
}
[Fact]
public async Task Without_a_ui_the_command_appends_a_custom_message()
{
using var h = new ConnectHarness();
await h.Run(Connect, h.Context(ui: null));
var (message, delivery, triggerTurn) = Assert.Single(h.Api.Sent);
Assert.Equal("calendar_connect", message.CustomType);
Assert.Equal("/calendar_connect needs an interactive UI (a chat front end such as the Telegram bot).", message.Text);
Assert.False(triggerTurn);
Assert.Empty(h.Http.Requests);
}
}

View File

@@ -0,0 +1,60 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class GoogleCalendarExtensionTests
{
[Fact]
public async Task Without_a_client_it_registers_nothing_and_logs_one_info_line()
{
var logger = new ListLogger();
var api = new CalendarFakeApi(logger);
var extension = new GoogleCalendarExtension(new GoogleCalendarOptions { GetEnv = _ => null });
Assert.Equal("google-calendar", extension.Name);
await extension.ActivateAsync(api, CancellationToken.None);
Assert.Empty(api.Tools);
Assert.Empty(api.Commands);
var entry = Assert.Single(logger.Entries);
Assert.Equal((LogLevel.Information, "google-calendar disabled: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET"), entry);
}
[Theory]
[InlineData("cid", null, "GOOGLE_CLIENT_SECRET")]
[InlineData(null, "csecret", "GOOGLE_CLIENT_ID")]
public async Task With_only_one_of_id_and_secret_it_logs_a_warning(string? id, string? secret, string missing)
{
var logger = new ListLogger();
var api = new CalendarFakeApi(logger);
await new GoogleCalendarExtension(new GoogleCalendarOptions { ClientId = id, ClientSecret = secret, GetEnv = _ => null }).ActivateAsync(api, CancellationToken.None);
Assert.Empty(api.Tools);
var entry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Warning, entry.Level);
Assert.Contains(missing, entry.Message);
Assert.DoesNotContain("csecret", entry.Message);
}
[Fact]
public void Configured_it_registers_the_calendar_tool_and_the_connect_command()
{
using var h = new ConnectHarness();
Assert.Equal("calendar", Assert.Single(h.Api.Tools).Name);
Assert.Contains("calendar_connect", h.Api.Commands.Keys);
Assert.All(h.Api.Commands.Values, c => Assert.NotNull(c.Description));
}
[Fact]
public async Task After_session_start_the_description_names_the_resolved_zone()
{
using var h = new ConnectHarness();
var tool = Assert.Single(h.Api.Tools);
Assert.Contains("the zone named in each result's header", tool.Description);
await h.Api.RaiseAsync(new SessionStartEvent(SessionStartReason.Startup), h.Context(null));
Assert.Contains("without an offset are in Europe/Berlin", tool.Description);
h.Connection().Store.Write(new StoredToken("rt-1", TimeZone: "America/New_York"));
Assert.Contains("without an offset are in America/New_York", Assert.Single(tool.PromptGuidelines));
}
}

View File

@@ -1,9 +1,15 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
@@ -43,3 +49,154 @@ internal sealed class CalendarFakeContext(string scope, TimeZoneInfo zone, IUser
public Task<SwitchResult> NewSessionAsync(NewSessionOptions? options = null, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false));
public Task<SwitchResult> SwitchSessionAsync(string sessionId, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false));
}
/// <summary>Records what the extension registers and sends; everything else is unsupported so misuse is loud.</summary>
internal sealed class CalendarFakeApi(ILogger? logger = null) : IExtensionApi
{
public List<ITool> Tools { get; } = [];
public Dictionary<string, CommandDefinition> Commands { get; } = new();
public List<(Type Event, Delegate Handler)> Handlers { get; } = [];
public List<(CustomMessage Message, MessageDelivery Delivery, bool TriggerTurn)> Sent { get; } = [];
public string ExtensionName => GoogleCalendarExtension.ExtensionName;
public ILogger Logger => logger ?? NullLogger.Instance;
public IEventBus Events => throw new NotSupportedException();
public IDisposable On<TEvent>(ExtensionHandler<TEvent> handler) where TEvent : ExtensionEvent
{
Handlers.Add((typeof(TEvent), handler));
return new Subscription();
}
public async Task RaiseAsync<TEvent>(TEvent e, IExtensionContext ctx) where TEvent : ExtensionEvent
{
foreach (var (type, handler) in Handlers.Where(h => h.Event == typeof(TEvent)))
await ((ExtensionHandler<TEvent>)handler)(e, ctx, CancellationToken.None);
}
public void RegisterTool(ITool tool) => Tools.Add(tool);
public void RegisterCommand(string name, CommandDefinition command) => Commands[name] = command;
public void RegisterProvider(ProviderRegistration provider) => throw new NotSupportedException();
public void UnregisterProvider(string providerId) => throw new NotSupportedException();
public void SendMessage(CustomMessage message, MessageDelivery delivery = MessageDelivery.Steer, bool triggerTurn = true) => Sent.Add((message, delivery, triggerTurn));
public void SendUserMessage(string text, IReadOnlyList<ImageContent>? images = null, MessageDelivery delivery = MessageDelivery.Steer) => throw new NotSupportedException();
public void AppendEntry(string customType, JsonElement? data = null) => throw new NotSupportedException();
public Task<bool> SetModelAsync(Model model, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ThinkingLevel GetThinkingLevel() => throw new NotSupportedException();
public void SetThinkingLevel(ThinkingLevel level) => throw new NotSupportedException();
public IReadOnlyList<string> GetActiveTools() => throw new NotSupportedException();
public void SetActiveTools(IEnumerable<string> names) => throw new NotSupportedException();
public IReadOnlyList<ToolInfo> GetAllTools() => throw new NotSupportedException();
public IReadOnlyList<CommandInfo> GetCommands() => throw new NotSupportedException();
public string? GetSessionName() => throw new NotSupportedException();
public void SetSessionName(string? name) => throw new NotSupportedException();
public Task<ExecResult> ExecAsync(string command, IReadOnlyList<string> args, ExecOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
private sealed class Subscription : IDisposable { public void Dispose() { } }
}
internal sealed class CalendarRecordingUi : IUserInterface
{
private readonly Lock _lock = new();
private readonly List<(string Message, NotifyKind Kind)> _notes = [];
public IReadOnlyList<(string Message, NotifyKind Kind)> Notes { get { lock (_lock) return _notes.ToList(); } }
public Task<string?> SelectAsync(string title, IReadOnlyList<string> options, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
public Task<bool> ConfirmAsync(string title, string message, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult(false);
public Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
public void Notify(string message, NotifyKind kind = NotifyKind.Info) { lock (_lock) _notes.Add((message, kind)); }
public void SetStatus(string key, string? text) { }
}
internal static class FlowDriver
{
/// <summary>Moves the fake clock exactly to each poll the flow schedules until the command returns.</summary>
public static async Task CompleteAsync(Task command, GoogleCalendarConnection connection, FakeTimeProvider time)
{
await Eventually.UntilAsync(() => command.IsCompleted || connection.Pending is not null, "the flow to start");
var flow = connection.Pending;
var seen = 0;
while (flow is not null)
{
await Eventually.UntilAsync(() => command.IsCompleted || flow.Parks > seen, "the next poll to be scheduled");
if (command.IsCompleted) break;
seen = flow.Parks;
var due = flow.NextPollAt - time.GetUtcNow();
if (due > TimeSpan.Zero) time.Advance(due);
}
await command.WaitAsync(TimeSpan.FromSeconds(10));
}
/// <summary>Waits until the chat's flow is parked on its first poll delay.</summary>
public static async Task<ConnectFlow> ParkedAsync(GoogleCalendarConnection connection)
{
await Eventually.UntilAsync(() => connection.Pending is { Parks: > 0 }, "the flow to wait for its first poll");
return connection.Pending!;
}
/// <summary>Coarse driving for several flows at once: advance in steps until <paramref name="done"/>.</summary>
public static async Task AdvanceUntilAsync(FakeTimeProvider time, Func<bool> done, TimeSpan step, int timeoutMs = 10000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (!done())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("Timed out driving the flows");
time.Advance(step);
await Task.Delay(5);
}
}
}
/// <summary>The activated extension over fake Google, a fake clock and a temp token root.</summary>
internal sealed class ConnectHarness : IDisposable
{
public static readonly DateTimeOffset Now = new(2026, 9, 26, 8, 0, 0, TimeSpan.Zero);
public const string ScopeA = "telegram-111";
public const string ScopeB = "telegram-222";
public const string DeviceRoute = "POST /device/code";
public const string TokenRoute = "POST /token";
public const string PrimaryRoute = "GET /calendar/v3/calendars/primary";
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private readonly string _root = Directory.CreateTempSubdirectory("gcal-connect-").FullName;
public ConnectHarness(ILogger? logger = null)
{
Http = new GoogleFakeHttp(Time);
Api = new CalendarFakeApi(logger);
Extension = new GoogleCalendarExtension(new GoogleCalendarOptions
{
ClientId = "cid", ClientSecret = "csecret", TokenDirectory = _root,
Http = Http.CreateClient(), Time = Time, Connections = Connections, GetEnv = _ => null,
});
Extension.ActivateAsync(Api, CancellationToken.None).AsTask().GetAwaiter().GetResult();
ScriptDevice("dev-1", "ABCD-EFGH");
Http.Json(PrimaryRoute, """{"id":"me@example.com","timeZone":"Europe/Berlin"}""");
}
public FakeTimeProvider Time { get; } = new(Now);
public GoogleFakeHttp Http { get; }
public GoogleCalendarConnections Connections { get; } = new();
public CalendarFakeApi Api { get; }
public GoogleCalendarExtension Extension { get; }
public string TokenRoot => _root;
public void ScriptDevice(string deviceCode, string userCode, int expiresIn = 1800, int interval = 5) =>
Http.Json(DeviceRoute, $$"""{"device_code":"{{deviceCode}}","user_code":"{{userCode}}","verification_url":"https://www.google.com/device","expires_in":{{expiresIn}},"interval":{{interval}}}""");
public static (System.Net.HttpStatusCode, string) Pending => (System.Net.HttpStatusCode.PreconditionRequired, """{"error":"authorization_pending"}""");
public static (System.Net.HttpStatusCode, string) SlowDown => (System.Net.HttpStatusCode.Forbidden, """{"error":"slow_down"}""");
public static (System.Net.HttpStatusCode, string) Approved(string refreshToken = "rt-1", string accessToken = "at-1") =>
(System.Net.HttpStatusCode.OK, $$"""{"access_token":"{{accessToken}}","expires_in":3599,"refresh_token":"{{refreshToken}}","scope":"https://www.googleapis.com/auth/calendar","token_type":"Bearer"}""");
public GoogleCalendarConnection Connection(string scope = ScopeA) => Extension.ConnectionFor(scope);
public CalendarFakeContext Context(CalendarRecordingUi? ui, string scope = ScopeA) => new(scope, Berlin, ui);
public Task Run(string command, CalendarFakeContext ctx) => Api.Commands[command].Handler("", ctx, CancellationToken.None);
public void Dispose()
{
foreach (var scope in new[] { ScopeA, ScopeB }) Connection(scope).CancelPending();
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
}