forked from admin/VolcanicAgenticCore
feat(google-calendar): calendar tool read actions over Calendar v3
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
public sealed record CalendarArgs(
|
||||
[property: Description("list, search, get, list_instances, create, update, delete or calendars")] string Action,
|
||||
[property: Description("Calendar id; default primary. action=calendars lists the others")] string? CalendarId = null,
|
||||
[property: Description("Window start (list, search, list_instances): RFC 3339, a local date-time or a date")] string? TimeMin = null,
|
||||
[property: Description("Window end, same forms as timeMin")] string? TimeMax = null,
|
||||
[property: Description("Free-text search (search only, required there)")] string? Query = null,
|
||||
[property: Description("Maximum events to return; default 25, at most 250")] int? MaxResults = null,
|
||||
[property: Description("list only: occurrences (default; every occurrence of recurring events) or series (recurring events once, with their RRULE)")] string? ListMode = null,
|
||||
[property: Description("Event id (get, list_instances, update, delete)")] string? EventId = null,
|
||||
[property: Description("update/delete of a recurring event: instance (one occurrence) or series; detected from the event when omitted")] string? Scope = null,
|
||||
[property: Description("Title (create, update)")] string? Summary = null,
|
||||
[property: Description("Notes (create, update)")] string? Description = null,
|
||||
[property: Description("Location (create, update)")] string? Location = null,
|
||||
[property: Description("YYYY-MM-DD for all day, or a date-time; without an offset it is wall time in timeZone")] string? Start = null,
|
||||
[property: Description("Like start. All-day ends are exclusive. Default on create: one day / one hour after start")] string? End = null,
|
||||
[property: Description("IANA zone for this event, e.g. Europe/Berlin; default the calendar's zone")] string? TimeZone = null,
|
||||
[property: Description("RFC 5545 lines, e.g. [\"RRULE:FREQ=WEEKLY;BYDAY=MO,WE\"]; [] on update turns a series into a single event")] IReadOnlyList<string>? Recurrence = null);
|
||||
|
||||
public sealed record CalendarEventDetails(string Id, string? Summary, JsonElement? Start, JsonElement? End, string? RecurringEventId,
|
||||
IReadOnlyList<string>? Recurrence, string? HtmlLink);
|
||||
|
||||
public sealed record CalendarResultDetails(string Action, IReadOnlyList<CalendarEventDetails> Events);
|
||||
|
||||
/// <summary>
|
||||
/// The <c>calendar</c> tool. Always registered while the OAuth client is configured; without a token for this chat it
|
||||
/// fails with the "/calendar_connect" hint. Uses only the chat's own connection (scope from the context).
|
||||
/// </summary>
|
||||
internal sealed partial class CalendarTool(GoogleCalendarSettings settings, GoogleCalendarConnections connections, Func<string?> currentZone) : Tool<CalendarArgs>
|
||||
{
|
||||
public const string ToolName = "calendar";
|
||||
private const int DefaultMax = 25;
|
||||
private const int MaxCap = 250;
|
||||
private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
|
||||
|
||||
public override string Name => ToolName;
|
||||
|
||||
public override string Description =>
|
||||
"Read and change the Google Calendar this chat connected with /calendar_connect. Actions: list, search, get, " +
|
||||
"list_instances, create, update, delete, calendars. " + Rules;
|
||||
|
||||
public override string? PromptSnippet => "calendar: read and change this chat's Google Calendar";
|
||||
|
||||
public override IReadOnlyList<string> PromptGuidelines => ["calendar: " + Rules];
|
||||
|
||||
public override ToolExecutionMode ExecutionMode => ToolExecutionMode.Parallel;
|
||||
|
||||
private string Rules =>
|
||||
$"Times without an offset are in {currentZone() ?? "the zone named in each result's header"}; all-day end dates are exclusive " +
|
||||
"(a one-day event on 2026-09-30 ends 2026-10-01); to change one date of a recurring event, get that occurrence's id with list_instances first.";
|
||||
|
||||
protected override async Task<ToolResult> ExecuteAsync(CalendarArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = invocation.Context;
|
||||
var connection = connections.For(settings, ctx.SessionManager.Scope);
|
||||
var resolved = connection.ResolveTimeZone(ctx.TimeZone, ctx.Logger);
|
||||
var eventZone = args.TimeZone is { Length: > 0 } requested
|
||||
? ZoneIds.TryFind(requested, out var z) ? z : throw new ArgumentException($"Unknown time zone \"{requested}\". Use an IANA id such as Europe/Berlin.")
|
||||
: resolved;
|
||||
var run = new Run(args, new GoogleCalendarApi(connection), resolved, eventZone, CalendarTime.Header(resolved, ctx.TimeZone),
|
||||
string.IsNullOrWhiteSpace(args.CalendarId) ? "primary" : args.CalendarId.Trim(), settings.Time.GetUtcNow(), cancellationToken);
|
||||
var action = (args.Action ?? "").Trim().ToLowerInvariant();
|
||||
return action switch
|
||||
{
|
||||
"list" => await ListAsync(run).ConfigureAwait(false),
|
||||
"search" => await SearchAsync(run).ConfigureAwait(false),
|
||||
"get" => await GetAsync(run).ConfigureAwait(false),
|
||||
"list_instances" => await ListInstancesAsync(run).ConfigureAwait(false),
|
||||
"calendars" => await CalendarsAsync(run).ConfigureAwait(false),
|
||||
"create" or "update" or "delete" => await WriteAsync(action, run).ConfigureAwait(false),
|
||||
_ => throw new ArgumentException($"Unknown action \"{args.Action}\". Use one of: list, search, get, list_instances, create, update, delete, calendars."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Everything one call needs, resolved once.</summary>
|
||||
private sealed record Run(CalendarArgs Args, GoogleCalendarApi Api, TimeZoneInfo Zone, TimeZoneInfo EventZone, string Header,
|
||||
string CalendarId, DateTimeOffset Now, CancellationToken Ct)
|
||||
{
|
||||
public string EventZoneName => ZoneIds.Name(EventZone);
|
||||
public int Max => Math.Clamp(Args.MaxResults ?? DefaultMax, 1, MaxCap);
|
||||
public string Bound(string? given, DateTimeOffset fallback, string field) =>
|
||||
given is { Length: > 0 } ? CalendarTime.ToRfc3339(given, EventZone, field) : CalendarTime.Utc(fallback);
|
||||
}
|
||||
|
||||
private async Task<ToolResult> ListAsync(Run run)
|
||||
{
|
||||
var mode = (run.Args.ListMode ?? "occurrences").Trim().ToLowerInvariant();
|
||||
if (mode is not ("occurrences" or "series")) throw new ArgumentException("listMode must be occurrences or series.");
|
||||
var query = new List<KeyValuePair<string, string>>
|
||||
{
|
||||
new("timeMin", run.Bound(run.Args.TimeMin, run.Now, "timeMin")),
|
||||
new("timeMax", run.Bound(run.Args.TimeMax, run.Now.AddDays(7), "timeMax")),
|
||||
new("singleEvents", mode == "occurrences" ? "true" : "false"),
|
||||
};
|
||||
if (mode == "occurrences") query.Add(new("orderBy", "startTime"));
|
||||
var items = await PageAsync(run, GoogleCalendarApi.Events(run.CalendarId), query, new CalendarCall("list", run.CalendarId)).ConfigureAwait(false);
|
||||
return Events("list", run, items);
|
||||
}
|
||||
|
||||
private async Task<ToolResult> SearchAsync(Run run)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(run.Args.Query)) throw new ArgumentException("search needs query.");
|
||||
var query = new List<KeyValuePair<string, string>>
|
||||
{
|
||||
new("q", run.Args.Query.Trim()),
|
||||
new("timeMin", run.Bound(run.Args.TimeMin, run.Now.AddDays(-30), "timeMin")),
|
||||
new("timeMax", run.Bound(run.Args.TimeMax, run.Now.AddDays(365), "timeMax")),
|
||||
new("singleEvents", "true"),
|
||||
new("orderBy", "startTime"),
|
||||
};
|
||||
var items = await PageAsync(run, GoogleCalendarApi.Events(run.CalendarId), query, new CalendarCall("search", run.CalendarId)).ConfigureAwait(false);
|
||||
return Events("search", run, items);
|
||||
}
|
||||
|
||||
private async Task<ToolResult> GetAsync(Run run)
|
||||
{
|
||||
var id = Require(run.Args.EventId, "get needs eventId.");
|
||||
var ev = await run.Api.SendAsync(HttpMethod.Get, GoogleCalendarApi.Event(run.CalendarId, id), null, new CalendarCall("get", run.CalendarId, id), run.Ct).ConfigureAwait(false);
|
||||
var text = run.Header + "\n" + Line(ev, run.Zone);
|
||||
if (Str(ev, "description") is { Length: > 0 } description) text += "\nDescription: " + description;
|
||||
return Result("get", text, [ev]);
|
||||
}
|
||||
|
||||
private async Task<ToolResult> ListInstancesAsync(Run run)
|
||||
{
|
||||
var id = Require(run.Args.EventId, "list_instances needs eventId.");
|
||||
var query = new List<KeyValuePair<string, string>>();
|
||||
if (run.Args.TimeMin is { Length: > 0 } min) query.Add(new("timeMin", CalendarTime.ToRfc3339(min, run.EventZone, "timeMin")));
|
||||
if (run.Args.TimeMax is { Length: > 0 } max) query.Add(new("timeMax", CalendarTime.ToRfc3339(max, run.EventZone, "timeMax")));
|
||||
var items = await PageAsync(run, GoogleCalendarApi.Event(run.CalendarId, id) + "/instances", query, new CalendarCall("list_instances", run.CalendarId, id)).ConfigureAwait(false);
|
||||
return Events("list_instances", run, items);
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> CalendarsAsync(Run run)
|
||||
{
|
||||
var items = await PageAsync(run, "users/me/calendarList", [], new CalendarCall("calendars", run.CalendarId)).ConfigureAwait(false);
|
||||
var sb = new StringBuilder(run.Header);
|
||||
foreach (var c in items)
|
||||
{
|
||||
sb.Append('\n').Append(Str(c, "id")).Append(" · ").Append(Str(c, "summary") ?? "(no name)")
|
||||
.Append(" · ").Append(Str(c, "accessRole")).Append(" · ").Append(Str(c, "timeZone"));
|
||||
if (c.TryGetProperty("primary", out var p) && p.ValueKind == JsonValueKind.True) sb.Append(" · primary");
|
||||
}
|
||||
return ToolResult.Text(sb.ToString(), new CalendarResultDetails("calendars", []));
|
||||
}
|
||||
|
||||
/// <summary>Follows <c>nextPageToken</c> until <see cref="Run.Max"/> items are collected.</summary>
|
||||
private static async Task<List<JsonElement>> PageAsync(Run run, string path, List<KeyValuePair<string, string>> query, CalendarCall call)
|
||||
{
|
||||
var items = new List<JsonElement>();
|
||||
string? pageToken = null;
|
||||
do
|
||||
{
|
||||
var pageQuery = new List<KeyValuePair<string, string>>(query) { new("maxResults", (run.Max - items.Count).ToString(Inv)) };
|
||||
if (pageToken is not null) pageQuery.Add(new("pageToken", pageToken));
|
||||
var page = await run.Api.SendAsync(HttpMethod.Get, path + "?" + GoogleCalendarApi.Query(pageQuery), null, call, run.Ct).ConfigureAwait(false);
|
||||
if (page.TryGetProperty("items", out var list) && list.ValueKind == JsonValueKind.Array)
|
||||
items.AddRange(list.EnumerateArray().Where(e => Str(e, "status") != "cancelled"));
|
||||
pageToken = Str(page, "nextPageToken");
|
||||
}
|
||||
while (pageToken is not null && items.Count < run.Max);
|
||||
return items.Take(run.Max).ToList();
|
||||
}
|
||||
|
||||
private static ToolResult Events(string action, Run run, List<JsonElement> items)
|
||||
{
|
||||
var text = items.Count == 0 ? run.Header + "\nNo events." : run.Header + "\n" + string.Join("\n", items.Select(e => Line(e, run.Zone)));
|
||||
return Result(action, text, items);
|
||||
}
|
||||
|
||||
private static ToolResult Result(string action, string text, IEnumerable<JsonElement> events) =>
|
||||
ToolResult.Text(text, new CalendarResultDetails(action, events.Select(Details).ToList()));
|
||||
|
||||
/// <summary><c><id> · <summary> · <range>[ @ location][ · recurring (series=…)][ · RRULE=…]</c></summary>
|
||||
internal static string Line(JsonElement ev, TimeZoneInfo zone)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(Str(ev, "id")).Append(" · ").Append(Str(ev, "summary") is { Length: > 0 } s ? s : "(no title)");
|
||||
if (ev.TryGetProperty("start", out var start))
|
||||
sb.Append(" · ").Append(CalendarTime.FormatRange(start, ev.TryGetProperty("end", out var end) ? end : start, zone));
|
||||
if (Str(ev, "location") is { Length: > 0 } location) sb.Append(" @ ").Append(location);
|
||||
if (Str(ev, "recurringEventId") is { Length: > 0 } series) sb.Append(" · recurring (series=").Append(series).Append(')');
|
||||
if (Recurrence(ev)?.FirstOrDefault(r => r.StartsWith("RRULE:", StringComparison.Ordinal)) is { } rrule) sb.Append(" · RRULE=").Append(rrule[6..]);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static CalendarEventDetails Details(JsonElement ev) => new(
|
||||
Str(ev, "id") ?? "", Str(ev, "summary"),
|
||||
ev.TryGetProperty("start", out var s) ? s.Clone() : null, ev.TryGetProperty("end", out var e) ? e.Clone() : null,
|
||||
Str(ev, "recurringEventId"), Recurrence(ev), Str(ev, "htmlLink"));
|
||||
|
||||
private static IReadOnlyList<string>? Recurrence(JsonElement ev) =>
|
||||
ev.TryGetProperty("recurrence", out var r) && r.ValueKind == JsonValueKind.Array ? r.EnumerateArray().Select(x => x.GetString() ?? "").ToList() : null;
|
||||
|
||||
private static string? Str(JsonElement e, string name) =>
|
||||
e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
|
||||
private static string Require(string? value, string message) => string.IsNullOrWhiteSpace(value) ? throw new ArgumentException(message) : value.Trim();
|
||||
|
||||
/// <summary>create, update, delete: Task 6.</summary>
|
||||
private static Task<ToolResult> WriteAsync(string action, Run run) =>
|
||||
throw new ArgumentException($"{action} is not available yet.");
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
internal readonly record struct CalendarCall(string Action, string CalendarId, string? EventId = null);
|
||||
|
||||
internal sealed record PrimaryCalendar(string? Account, string? TimeZone);
|
||||
|
||||
/// <summary>Calendar v3 over raw HTTP for one chat: bearer token from the connection, one refresh-and-retry on 401.</summary>
|
||||
internal sealed class GoogleCalendarApi(GoogleCalendarConnection connection)
|
||||
{
|
||||
public const string BaseUrl = "https://www.googleapis.com/calendar/v3/";
|
||||
|
||||
public static string Events(string calendarId) => $"calendars/{Uri.EscapeDataString(calendarId)}/events";
|
||||
|
||||
public static string Event(string calendarId, string eventId) => $"{Events(calendarId)}/{Uri.EscapeDataString(eventId)}";
|
||||
|
||||
public static string Query(IEnumerable<KeyValuePair<string, string>> pairs) =>
|
||||
string.Join("&", pairs.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}"));
|
||||
|
||||
public async Task<JsonElement> SendAsync(HttpMethod method, string pathAndQuery, JsonObject? body, CalendarCall call, CancellationToken cancellationToken)
|
||||
{
|
||||
var http = connection.Settings.Http;
|
||||
var response = await SendOnceAsync(http, method, pathAndQuery, body,
|
||||
await connection.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
response.Dispose();
|
||||
connection.Invalidate();
|
||||
response = await SendOnceAsync(http, method, pathAndQuery, body,
|
||||
await connection.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
using (response)
|
||||
{
|
||||
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode) return string.IsNullOrWhiteSpace(text) ? VolcanicJson.EmptyObject : VolcanicJson.Parse(text);
|
||||
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone && call.EventId is not null)
|
||||
throw new GoogleCalendarException($"Event {call.EventId} not found in {call.CalendarId}.");
|
||||
throw new GoogleCalendarException($"Google Calendar {call.Action} failed: {(int)response.StatusCode} {ErrorMessage(text) ?? response.ReasonPhrase}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The account (primary calendar id = email) and zone, with a token that isn't stored yet.</summary>
|
||||
public static async Task<PrimaryCalendar> GetPrimaryAsync(HttpClient http, string accessToken, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await SendOnceAsync(http, HttpMethod.Get, "calendars/primary", null, accessToken, cancellationToken).ConfigureAwait(false);
|
||||
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new GoogleCalendarException($"calendars/primary failed: {(int)response.StatusCode} {ErrorMessage(text) ?? response.ReasonPhrase}");
|
||||
var json = VolcanicJson.Parse(text);
|
||||
return new PrimaryCalendar(
|
||||
json.TryGetProperty("id", out var id) ? id.GetString() : null,
|
||||
json.TryGetProperty("timeZone", out var zone) ? zone.GetString() : null);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> SendOnceAsync(HttpClient http, HttpMethod method, string pathAndQuery, JsonObject? body, string accessToken, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, BaseUrl + pathAndQuery);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
if (body is not null) request.Content = new StringContent(body.ToJsonString(VolcanicJson.Options), Encoding.UTF8, "application/json");
|
||||
return await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string? ErrorMessage(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(text) is JsonObject o && o["error"] is JsonObject e && e["message"] is JsonValue m && m.TryGetValue<string>(out var s) ? s : null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
|
||||
|
||||
public class CalendarToolReadTests
|
||||
{
|
||||
private static readonly string Weekly = Ev.Timed("ev-1_20260930T080000Z", "Standup", "2026-09-30T10:00:00+02:00", "2026-09-30T10:15:00+02:00", recurringEventId: "ev-1");
|
||||
private static readonly string Single = Ev.Timed("ev-2", "Dentist", "2026-10-01T14:00:00+02:00", "2026-10-01T15:00:00+02:00", location: "Main St 1");
|
||||
|
||||
[Fact]
|
||||
public async Task List_defaults_to_the_next_seven_days_as_single_events_ordered_by_start()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Single));
|
||||
var result = await h.RunAsync(new { action = "list" });
|
||||
|
||||
var request = Assert.Single(h.CalendarRequests);
|
||||
Assert.Equal("Bearer at-1", request.Authorization);
|
||||
Assert.Equal("2026-09-26T08:00:00Z", request.Query["timeMin"]);
|
||||
Assert.Equal("2026-10-03T08:00:00Z", request.Query["timeMax"]);
|
||||
Assert.Equal("true", request.Query["singleEvents"]);
|
||||
Assert.Equal("startTime", request.Query["orderBy"]);
|
||||
Assert.Equal("25", request.Query["maxResults"]);
|
||||
Assert.Equal("Times in Europe/Berlin.\nev-2 · Dentist · Thu 1 Oct 14:00–15:00 @ Main St 1", result.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_marks_recurring_occurrences_with_their_series()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Weekly));
|
||||
var result = await h.RunAsync(new { action = "list" });
|
||||
Assert.Contains("ev-1_20260930T080000Z · Standup · Wed 30 Sep 10:00–10:15 · recurring (series=ev-1)", result.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_series_mode_requests_masters_and_shows_the_rrule()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Ev.Timed("ev-1", "Standup", "2026-09-28T10:00:00+02:00", "2026-09-28T10:15:00+02:00",
|
||||
recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"])));
|
||||
var result = await h.RunAsync(new { action = "list", listMode = "series" });
|
||||
var query = Assert.Single(h.CalendarRequests).Query;
|
||||
Assert.Equal("false", query["singleEvents"]);
|
||||
Assert.False(query.ContainsKey("orderBy"));
|
||||
Assert.Contains("ev-1 · Standup · Mon 28 Sep 10:00–10:15 · RRULE=FREQ=WEEKLY;BYDAY=MO,WE", result.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_rejects_an_unknown_list_mode()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "list", listMode = "everything" }));
|
||||
Assert.Equal("listMode must be occurrences or series.", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TimeMin_given_as_a_date_is_sent_as_rfc3339_in_the_zone()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List());
|
||||
await h.RunAsync(new { action = "list", timeMin = "2026-09-30", timeMax = "2026-10-01T18:00" });
|
||||
var query = Assert.Single(h.CalendarRequests).Query;
|
||||
Assert.Equal("2026-09-30T00:00:00+02:00", query["timeMin"]);
|
||||
Assert.Equal("2026-10-01T18:00:00+02:00", query["timeMax"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Max_results_is_capped_at_250()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List());
|
||||
await h.RunAsync(new { action = "list", maxResults = 1000 });
|
||||
Assert.Equal("250", Assert.Single(h.CalendarRequests).Query["maxResults"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_requires_query()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "search" }));
|
||||
Assert.Equal("search needs query.", ex.Message);
|
||||
Assert.Empty(h.CalendarRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_sends_q_and_the_default_window()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Single));
|
||||
await h.RunAsync(new { action = "search", query = "dentist" });
|
||||
var query = Assert.Single(h.CalendarRequests).Query;
|
||||
Assert.Equal("dentist", query["q"]);
|
||||
Assert.Equal("2026-08-27T08:00:00Z", query["timeMin"]);
|
||||
Assert.Equal("2027-09-26T08:00:00Z", query["timeMax"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_renders_one_event_with_its_description()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
var withDescription = Single.TrimEnd('}') + ",\"description\":\"Bring the card\"}";
|
||||
h.Http.Json("GET /calendar/v3/calendars/primary/events/ev-2", withDescription);
|
||||
var result = await h.RunAsync(new { action = "get", eventId = "ev-2" });
|
||||
Assert.Equal("Times in Europe/Berlin.\nev-2 · Dentist · Thu 1 Oct 14:00–15:00 @ Main St 1\nDescription: Bring the card", result.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_instances_follows_next_page_tokens_up_to_max_results()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
string Occ(int day) => Ev.Timed($"ev-1_202610{day:00}", "Standup", $"2026-10-{day:00}T10:00:00+02:00", $"2026-10-{day:00}T10:15:00+02:00", recurringEventId: "ev-1");
|
||||
h.Http.Json("GET /calendar/v3/calendars/primary/events/ev-1/instances",
|
||||
(HttpStatusCode.OK, Ev.Page("p2", Occ(5), Occ(7), Occ(12))),
|
||||
(HttpStatusCode.OK, Ev.Page("p3", Occ(14), Occ(19))));
|
||||
var result = await h.RunAsync(new { action = "list_instances", eventId = "ev-1", maxResults = 4 });
|
||||
|
||||
var requests = h.CalendarRequests;
|
||||
Assert.Equal(2, requests.Count);
|
||||
Assert.False(requests[0].Query.ContainsKey("pageToken"));
|
||||
Assert.Equal("p2", requests[1].Query["pageToken"]);
|
||||
Assert.Equal(5, result.TextContent.Split('\n').Length); // header + 4 occurrences
|
||||
Assert.DoesNotContain("ev-1_20261019", result.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Calendars_lists_entries_with_primary_marked()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
h.Http.Json("GET /calendar/v3/users/me/calendarList", """
|
||||
{"items":[{"id":"me@example.com","summary":"Me","accessRole":"owner","timeZone":"Europe/Berlin","primary":true},
|
||||
{"id":"family@group.calendar.google.com","summary":"Family","accessRole":"writer","timeZone":"Europe/Berlin"}]}
|
||||
""");
|
||||
var result = await h.RunAsync(new { action = "calendars" });
|
||||
Assert.Contains("me@example.com · Me · owner · Europe/Berlin · primary", result.TextContent);
|
||||
Assert.Contains("family@group.calendar.google.com · Family · writer · Europe/Berlin", result.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_header_names_both_zones_when_the_calendar_and_the_chat_differ()
|
||||
{
|
||||
using var h = new CalendarToolHarness(calendarZone: "America/New_York");
|
||||
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Weekly));
|
||||
var result = await h.RunAsync(new { action = "list" });
|
||||
Assert.StartsWith("Times in America/New_York (the calendar's zone; this chat is in Europe/Berlin).\n", result.TextContent);
|
||||
Assert.Contains("Wed 30 Sep 04:00–04:15", result.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_stored_zone_the_chat_zone_is_used()
|
||||
{
|
||||
using var h = new CalendarToolHarness(calendarZone: null) { ChatZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo") };
|
||||
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Weekly));
|
||||
var result = await h.RunAsync(new { action = "list" });
|
||||
Assert.StartsWith("Times in Asia/Tokyo.\n", result.TextContent);
|
||||
Assert.Contains("Wed 30 Sep 17:00–17:15", result.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_carry_the_raw_events()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Weekly));
|
||||
var result = await h.RunAsync(new { action = "list" });
|
||||
var details = result.Details!.Value;
|
||||
Assert.Equal("list", details.GetProperty("action").GetString());
|
||||
var ev = Assert.Single(details.GetProperty("events").EnumerateArray().ToList());
|
||||
Assert.Equal("ev-1", ev.GetProperty("recurringEventId").GetString());
|
||||
Assert.Equal("2026-09-30T10:00:00+02:00", ev.GetProperty("start").GetProperty("dateTime").GetString());
|
||||
Assert.StartsWith("https://calendar.google.com/", ev.GetProperty("htmlLink").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_connection_the_tool_tells_the_model_to_ask_for_calendar_connect()
|
||||
{
|
||||
using var h = new CalendarToolHarness(connected: false);
|
||||
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => h.RunAsync(new { action = "list" }));
|
||||
Assert.True(ex.NotConnected);
|
||||
Assert.Contains("/calendar_connect in this chat", ex.Message);
|
||||
Assert.Empty(h.Http.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_grant_tells_the_model_to_ask_for_a_reconnect()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
h.Http.Json("POST /token", (HttpStatusCode.BadRequest, """{"error":"invalid_grant"}"""));
|
||||
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => h.RunAsync(new { action = "list" }));
|
||||
Assert.Equal("Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat.", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_401_from_the_events_endpoint_refreshes_once_and_retries()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
h.Http.Json("POST /token", (HttpStatusCode.OK, """{"access_token":"at-1","expires_in":3600}"""), (HttpStatusCode.OK, """{"access_token":"at-2","expires_in":3600}"""));
|
||||
h.Http.Json(CalendarToolHarness.EventsRoute,
|
||||
(HttpStatusCode.Unauthorized, """{"error":{"code":401,"message":"Invalid Credentials"}}"""),
|
||||
(HttpStatusCode.OK, Ev.List(Single)));
|
||||
var result = await h.RunAsync(new { action = "list" });
|
||||
Assert.Equal(2, h.Http.To("POST /token").Count);
|
||||
Assert.Equal("Bearer at-2", h.CalendarRequests[1].Authorization);
|
||||
Assert.Contains("Dentist", result.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_second_401_is_reported_not_retried_again()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
h.Http.Json(CalendarToolHarness.EventsRoute, (HttpStatusCode.Unauthorized, """{"error":{"code":401,"message":"Invalid Credentials"}}"""));
|
||||
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => h.RunAsync(new { action = "list" }));
|
||||
Assert.Equal("Google Calendar list failed: 401 Invalid Credentials", ex.Message);
|
||||
Assert.Equal(2, h.CalendarRequests.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.NotFound)]
|
||||
[InlineData(HttpStatusCode.Gone)]
|
||||
public async Task A_missing_event_names_the_event_and_calendar(HttpStatusCode status)
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
h.Http.Json("GET /calendar/v3/calendars/primary/events/nope", (status, """{"error":{"code":404,"message":"Not Found"}}"""));
|
||||
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => h.RunAsync(new { action = "get", eventId = "nope" }));
|
||||
Assert.Equal("Event nope not found in primary.", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Other_errors_carry_the_status_and_googles_message()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
h.Http.Json(CalendarToolHarness.EventsRoute, (HttpStatusCode.Forbidden, """{"error":{"code":403,"message":"Rate Limit Exceeded"}}"""));
|
||||
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => h.RunAsync(new { action = "list" }));
|
||||
Assert.Equal("Google Calendar list failed: 403 Rate Limit Exceeded", ex.Message);
|
||||
Assert.Single(h.CalendarRequests); // no retry
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unknown_action_is_rejected()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "purge" }));
|
||||
Assert.StartsWith("Unknown action \"purge\".", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Description_and_guideline_state_the_time_rules()
|
||||
{
|
||||
using var h = new CalendarToolHarness();
|
||||
var tool = new CalendarTool(h.Settings, h.Connections, () => "Europe/Berlin");
|
||||
Assert.Contains("without an offset are in Europe/Berlin", tool.Description);
|
||||
var guideline = Assert.Single(tool.PromptGuidelines);
|
||||
Assert.Contains("Europe/Berlin", guideline);
|
||||
Assert.Contains("exclusive", guideline);
|
||||
Assert.Contains("list_instances", guideline);
|
||||
Assert.Equal(Abstractions.Tools.ToolExecutionMode.Parallel, tool.ExecutionMode);
|
||||
Assert.Equal(JsonValueKind.Object, tool.Parameters.ValueKind);
|
||||
Assert.Equal("action", tool.Parameters.GetProperty("required")[0].GetString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Abstractions.Sessions;
|
||||
using Volcanic.AgenticCore.Abstractions.Ui;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
|
||||
|
||||
internal sealed class FakeSessionManager(string scope) : IReadOnlySessionManager
|
||||
{
|
||||
public string SessionId => "session-1";
|
||||
public string Scope => scope;
|
||||
public string Cwd => Path.GetTempPath();
|
||||
public string? Name => null;
|
||||
public string? LeafId => null;
|
||||
public IReadOnlyList<SessionEntry> GetEntries() => [];
|
||||
public IReadOnlyList<SessionEntry> GetBranch() => [];
|
||||
public SessionEntry? GetEntry(string id) => null;
|
||||
}
|
||||
|
||||
/// <summary>A command/tool context for one chat: its scope, its zone and (optionally) a UI.</summary>
|
||||
internal sealed class CalendarFakeContext(string scope, TimeZoneInfo zone, IUserInterface? ui = null, ILogger? logger = null) : IExtensionCommandContext
|
||||
{
|
||||
public string Cwd => Path.GetTempPath();
|
||||
public string SessionId => "session-1";
|
||||
public bool HasUi => ui is not null;
|
||||
public IUserInterface Ui => ui ?? NullUserInterface.Instance;
|
||||
public IReadOnlySessionManager SessionManager { get; } = new FakeSessionManager(scope);
|
||||
public IModelRegistry ModelRegistry => throw new NotSupportedException();
|
||||
public Model? Model => null;
|
||||
public ThinkingLevel ThinkingLevel => ThinkingLevel.Off;
|
||||
public TimeZoneInfo TimeZone => zone;
|
||||
public CancellationToken Signal => CancellationToken.None;
|
||||
public ILogger Logger => 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) { }
|
||||
public Task WaitForIdleAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
|
||||
|
||||
/// <summary>A connected chat (refresh token rt-1, access token at-1), the tool, and scripted Google routes.</summary>
|
||||
internal sealed class CalendarToolHarness : IDisposable
|
||||
{
|
||||
public static readonly DateTimeOffset Now = new(2026, 9, 26, 8, 0, 0, TimeSpan.Zero);
|
||||
public const string Scope = "telegram-111";
|
||||
public const string EventsRoute = "GET /calendar/v3/calendars/primary/events";
|
||||
|
||||
private readonly string _root = Directory.CreateTempSubdirectory("gcal-tool-").FullName;
|
||||
|
||||
public CalendarToolHarness(bool connected = true, string? calendarZone = "Europe/Berlin")
|
||||
{
|
||||
Http = new GoogleFakeHttp(Time);
|
||||
Settings = new GoogleCalendarSettings("cid", "csecret", _root, Http.CreateClient(), Time);
|
||||
Tool = new CalendarTool(Settings, Connections, () => null);
|
||||
Http.Json("POST /token", """{"access_token":"at-1","expires_in":3600}""");
|
||||
if (connected) Connection.Store.Write(new StoredToken("rt-1", GoogleOAuthClient.CalendarScope, "me@example.com", calendarZone, Now));
|
||||
}
|
||||
|
||||
public FakeTimeProvider Time { get; } = new(Now);
|
||||
public GoogleFakeHttp Http { get; }
|
||||
public GoogleCalendarSettings Settings { get; }
|
||||
public GoogleCalendarConnections Connections { get; } = new();
|
||||
public GoogleCalendarConnection Connection => Connections.For(Settings, Scope);
|
||||
public TimeZoneInfo ChatZone { get; set; } = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
public CalendarTool Tool { get; }
|
||||
|
||||
public IReadOnlyList<RecordedRequest> CalendarRequests => Http.Requests.Where(r => r.Uri.Host == "www.googleapis.com").ToList();
|
||||
|
||||
public Task<ToolResult> RunAsync(object args)
|
||||
{
|
||||
var call = new ToolCallContent("call-1", CalendarTool.ToolName, VolcanicJson.ToElement(args));
|
||||
return Tool.ExecuteAsync(call, new ToolInvocation("call-1", new CalendarFakeContext(Scope, ChatZone), null), CancellationToken.None);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Google event JSON as the Calendar API returns it.</summary>
|
||||
internal static class Ev
|
||||
{
|
||||
public static string Timed(string id, string summary, string start, string end, string? recurringEventId = null,
|
||||
string[]? recurrence = null, string? location = null)
|
||||
{
|
||||
var ev = new JsonObject
|
||||
{
|
||||
["id"] = id, ["summary"] = summary, ["htmlLink"] = $"https://calendar.google.com/event?eid={id}",
|
||||
["start"] = new JsonObject { ["dateTime"] = start, ["timeZone"] = "Europe/Berlin" },
|
||||
["end"] = new JsonObject { ["dateTime"] = end, ["timeZone"] = "Europe/Berlin" },
|
||||
};
|
||||
if (recurringEventId is not null) ev["recurringEventId"] = recurringEventId;
|
||||
if (recurrence is not null) ev["recurrence"] = new JsonArray(recurrence.Select(r => (JsonNode)r).ToArray());
|
||||
if (location is not null) ev["location"] = location;
|
||||
return ev.ToJsonString();
|
||||
}
|
||||
|
||||
public static string AllDay(string id, string summary, string start, string end) =>
|
||||
new JsonObject { ["id"] = id, ["summary"] = summary, ["start"] = new JsonObject { ["date"] = start }, ["end"] = new JsonObject { ["date"] = end } }.ToJsonString();
|
||||
|
||||
public static string List(params string[] items) => $"{{\"items\":[{string.Join(",", items)}]}}";
|
||||
|
||||
public static string Page(string? nextPageToken, params string[] items) =>
|
||||
nextPageToken is null ? List(items) : $"{{\"items\":[{string.Join(",", items)}],\"nextPageToken\":\"{nextPageToken}\"}}";
|
||||
}
|
||||
Reference in New Issue
Block a user