feat(telegram): A2A client per chat behind A2AClient__Enabled, watch results as follow-ups, /a2a lists agents
This commit is contained in:
163
tests/Volcanic.AgenticCore.Telegram.Tests/A2AClientHostTests.cs
Normal file
163
tests/Volcanic.AgenticCore.Telegram.Tests/A2AClientHostTests.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Volcanic.AgenticCore.A2A.Server;
|
||||
using Volcanic.AgenticCore.Extensions.A2A;
|
||||
using Volcanic.AgenticCore.Telegram.A2A;
|
||||
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
||||
using Volcanic.AgenticCore.Testing;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
public class A2AClientHostTests
|
||||
{
|
||||
private static TelegramA2AResultDelivery DeliveryFor(ChatHost host) =>
|
||||
new(host.Chats, host.Settings, host.Options, TimeProvider.System, NullLogger<TelegramA2AResultDelivery>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task ClientDisabled_NoA2aTool()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
var chat = await host.ChatAsync();
|
||||
Assert.DoesNotContain(chat.Session.Tools, t => t.Name == "a2a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientEnabled_ChatHasA2aTool()
|
||||
{
|
||||
await using var host = new ChatHost(a2aClient: new A2AClientSettings { Enabled = true });
|
||||
var chat = await host.ChatAsync();
|
||||
Assert.Contains(chat.Session.Tools, t => t.Name == "a2a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResultDelivery_GoesToChatWithTimestampHeader()
|
||||
{
|
||||
await using var host = new ChatHost(a2aClient: new A2AClientSettings { Enabled = true });
|
||||
host.Faux.Enqueue(FauxTurn.Say("Got the result"));
|
||||
var delivery = DeliveryFor(host);
|
||||
Assert.True(await delivery.DeliverAsync("1001", "[A2A result from \"remote\", task t1 (completed)]\nDone", CancellationToken.None));
|
||||
var prompt = host.Faux.Requests.Last().Messages.Last().Text;
|
||||
Assert.Contains("[A2A result from \"remote\"", prompt);
|
||||
Assert.Matches(@"^\[.*\d{4}", prompt); // the local-time header comes first
|
||||
await (await host.ChatAsync()).Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains(host.Texts, t => t.Contains("Got the result"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResultDelivery_UnknownOrNotAllowedTarget_False()
|
||||
{
|
||||
await using var host = new ChatHost(a2aClient: new A2AClientSettings { Enabled = true });
|
||||
var delivery = DeliveryFor(host);
|
||||
Assert.False(await delivery.DeliverAsync("999", "x", CancellationToken.None));
|
||||
Assert.False(await delivery.DeliverAsync("not-a-chat", "x", CancellationToken.None));
|
||||
Assert.Equal(["1001"], delivery.KnownTargets());
|
||||
Assert.Null(delivery.WorkspaceFor("not-a-chat"));
|
||||
Assert.Equal(host.WorkspaceOf(), delivery.WorkspaceFor("1001"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A2aCommand_ListsAgentsAndWatches()
|
||||
{
|
||||
await using var host = new ChatHost(a2aClient: new A2AClientSettings { Enabled = true });
|
||||
var ws = host.WorkspaceOf();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(AgentsFile.PathFor(ws))!);
|
||||
File.WriteAllText(AgentsFile.PathFor(ws), """
|
||||
[ { "name": "travel-bot", "url": "https://travel.example/", "key": "agent-key-123456789" },
|
||||
{ "name": "short", "url": "https://short.example/", "key": "short-key" } ]
|
||||
""");
|
||||
await host.SendAsync("/a2a");
|
||||
await host.SettleAsync();
|
||||
var reply = host.LastReply();
|
||||
Assert.Contains("travel-bot", reply);
|
||||
Assert.Contains("https://travel.example/", reply);
|
||||
Assert.DoesNotContain("agent-key-123456789", reply);
|
||||
Assert.Contains("…6789", reply);
|
||||
Assert.DoesNotContain("short-key", reply);
|
||||
Assert.Contains("Watches", reply);
|
||||
Assert.Contains("A2A server: off", reply);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A2aCommand_BothOff_SaysHowToEnable()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
await host.SendAsync("/a2a");
|
||||
await host.SettleAsync();
|
||||
var reply = host.LastReply();
|
||||
Assert.Contains("A2A is off", reply);
|
||||
Assert.Contains("A2A__Enabled", reply);
|
||||
Assert.Contains("A2AClient__Enabled", reply);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A2aCommand_ServerOnly_ShowsCallersAndClientOff()
|
||||
{
|
||||
await using var host = new ChatHost(a2a: new A2AHostOptions { Enabled = true });
|
||||
await host.SendAsync("/a2a");
|
||||
await host.SettleAsync();
|
||||
var reply = host.LastReply();
|
||||
Assert.Contains("A2A callers", reply);
|
||||
Assert.Contains("A2A client: off", reply);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Setup_ServerOff_NoPushBaseUrlEvenIfConfigured()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var client = new A2AClientSettings { Enabled = true, PushBaseUrl = "https://configured.example" };
|
||||
var server = new A2AHostOptions { Enabled = false, PublicUrl = "https://bot.example" };
|
||||
TelegramA2AClientSetup.Register(services, client, server, new A2AWatchRegistry(TimeProvider.System, new HttpClientHandler()));
|
||||
Assert.Null(client.PushBaseUrl);
|
||||
Assert.Null(server.OnPush);
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(A2AWatchRegistry));
|
||||
Assert.Contains(services, d => d.ImplementationType == typeof(A2AWatchHostedService));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Setup_ServerOn_PushRoutedIntoTheRegistry()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var client = new A2AClientSettings { Enabled = true };
|
||||
var server = new A2AHostOptions { Enabled = true, PublicUrl = "https://bot.example" };
|
||||
await using var registry = new A2AWatchRegistry(TimeProvider.System, new HttpClientHandler());
|
||||
TelegramA2AClientSetup.Register(services, client, server, registry);
|
||||
Assert.Equal("https://bot.example", client.PushBaseUrl);
|
||||
Assert.NotNull(server.OnPush);
|
||||
Assert.False(await server.OnPush!("unknown", "token", JsonDocument.Parse("{}").RootElement, CancellationToken.None));
|
||||
Assert.Contains("push at https://bot.example", TelegramA2AClientSetup.Describe(client));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Setup_ClientOff_RegistersOnlyTheSettings()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var client = new A2AClientSettings { PushBaseUrl = "https://configured.example" };
|
||||
var server = new A2AHostOptions { Enabled = true, PublicUrl = "https://bot.example" };
|
||||
TelegramA2AClientSetup.Register(services, client, server, new A2AWatchRegistry(TimeProvider.System, new HttpClientHandler()));
|
||||
Assert.Null(server.OnPush);
|
||||
Assert.Null(client.PushBaseUrl);
|
||||
Assert.Equal(typeof(A2AClientSettings), Assert.Single(services).ServiceType);
|
||||
Assert.Equal("A2A client: off", TelegramA2AClientSetup.Describe(client));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostedService_RestoreRunsInTheBackground()
|
||||
{
|
||||
var settings = new A2AClientSettings { Enabled = true };
|
||||
await using var host = new ChatHost(a2aClient: settings);
|
||||
var ws = host.WorkspaceOf();
|
||||
PendingFile.Upsert(ws, new WatchRecord("w1", ChatHost.Target, "remote", "t1", "c1", null, DateTimeOffset.UtcNow, WatchSources.Poll,
|
||||
Undelivered: "[A2A result from \"remote\", task t1 (completed)]\nKept"));
|
||||
host.Faux.Enqueue(FauxTurn.Say("Delivered late").WithDelay(TimeSpan.FromSeconds(2)));
|
||||
var service = new A2AWatchHostedService(host.A2AWatchRegistry!, settings, DeliveryFor(host), NullLogger<A2AWatchHostedService>.Instance);
|
||||
var watch = Stopwatch.StartNew();
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
Assert.True(watch.Elapsed < TimeSpan.FromSeconds(1), $"StartAsync took {watch.Elapsed}");
|
||||
await Wait.UntilAsync(() => host.Texts.Any(t => t.Contains("Delivered late")), "the kept result to be delivered", 15_000);
|
||||
await Wait.UntilAsync(() => PendingFile.Read(ws).Count == 0, "the record to be removed");
|
||||
await ((IHostedService)service).StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
Volcanic.AgenticCore.Extensions.GoogleCalendar.GoogleCalendarOptions? calendar = null,
|
||||
Volcanic.AgenticCore.Telegram.Speech.ISpeechToText? speech = null, string? root = null, SchedulerOptions? scheduler = null,
|
||||
IScriptRunner? runner = null, TriggerOptions? triggers = null, IJevClient? jev = null,
|
||||
Volcanic.AgenticCore.A2A.Server.A2AHostOptions? a2a = null)
|
||||
Volcanic.AgenticCore.A2A.Server.A2AHostOptions? a2a = null, Volcanic.AgenticCore.Extensions.A2A.A2AClientSettings? a2aClient = null)
|
||||
{
|
||||
_ownsRoot = root is null;
|
||||
_root = root ?? Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N"));
|
||||
@@ -115,16 +115,21 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
Triggers = new TriggerServices(Engine, Groups, new TelegramGroupMembership(Bot), triggers);
|
||||
Feed = new GroupFeed(Groups, Engine, Bot, Options, NullLogger<GroupFeed>.Instance);
|
||||
}
|
||||
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar, Settings, Zones, Scheduler, Impersonation, Triggers);
|
||||
if (a2aClient is not null) A2AWatchRegistry = new Volcanic.AgenticCore.Extensions.A2A.A2AWatchRegistry(clock, new HttpClientHandler());
|
||||
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar, Settings, Zones, Scheduler, Impersonation, Triggers,
|
||||
a2aClient, A2AWatchRegistry);
|
||||
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, Zones, Scheduler, Triggers,
|
||||
a2a is null ? null : Microsoft.Extensions.Options.Options.Create(a2a), clock), sender, Composer, NullLogger<UpdateRouter>.Instance, Impersonation, Feed);
|
||||
a2a is null ? null : Microsoft.Extensions.Options.Options.Create(a2a), clock, a2aClient, A2AWatchRegistry), sender, Composer, NullLogger<UpdateRouter>.Instance, Impersonation, Feed);
|
||||
}
|
||||
|
||||
private readonly TriggerDispatcher? _dispatcher;
|
||||
|
||||
/// <summary>The watch registry when the host was built with <c>a2aClient:</c> settings (this host's own, not the shared one).</summary>
|
||||
public Volcanic.AgenticCore.Extensions.A2A.A2AWatchRegistry? A2AWatchRegistry { get; }
|
||||
|
||||
/// <summary>Set when the host was built with <c>triggers:</c> options.</summary>
|
||||
public TriggerServices? Triggers { get; }
|
||||
public TriggerEngine? Engine { get; }
|
||||
@@ -175,6 +180,7 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
Engine?.Dispose();
|
||||
_dispatcher?.Dispose();
|
||||
await Chats.DisposeAsync();
|
||||
if (A2AWatchRegistry is not null) await A2AWatchRegistry.DisposeAsync();
|
||||
await _services.DisposeAsync();
|
||||
if (_ownsRoot)
|
||||
try { Directory.Delete(_root, true); } catch (IOException) { }
|
||||
|
||||
Reference in New Issue
Block a user