Files
VolcanicAgenticCore/tests/Volcanic.AgenticCore.Telegram.Tests/A2AHostTests.cs

155 lines
7.2 KiB
C#

using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.A2A.Server;
using Volcanic.AgenticCore.A2A.Server.Agent;
using Volcanic.AgenticCore.A2A.Server.Callers;
using Volcanic.AgenticCore.A2A.Server.Hosting;
using Volcanic.AgenticCore.A2A.Server.Tasks;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.A2A;
using Volcanic.AgenticCore.Telegram.Tests.Support;
namespace Volcanic.AgenticCore.Telegram.Tests;
public class A2AHostTests
{
private static void WriteCallers(string workspace, string json)
{
Directory.CreateDirectory(Path.GetDirectoryName(CallersFile.PathFor(workspace))!);
File.WriteAllText(CallersFile.PathFor(workspace), json);
}
[Fact]
public async Task CallerDirectory_OnlyAllowedChats_LiveEdit()
{
await using var host = new ChatHost();
var directory = new WorkspaceCallerDirectory(TelegramA2ASetup.Workspaces(host.Options), NullLogger<WorkspaceCallerDirectory>.Instance);
var allowed = host.WorkspaceOf(Updates.AllowedUser);
var stranger = Path.Combine(host.Options.WorkspaceRoot, "999");
WriteCallers(stranger, """[ { "name": "x", "key": "k-stranger" } ]""");
Assert.Empty(directory.Find("k-stranger"));
Assert.Empty(directory.Find("k1"));
WriteCallers(allowed, """[ { "name": "travel-bot", "key": "k1" } ]""");
var caller = Assert.Single(directory.Find("k1"));
Assert.Equal($"telegram-{Updates.AllowedUser}", caller.ScopePrefix);
Assert.Equal(Updates.AllowedUser.ToString(), caller.OwnerId);
}
[Fact]
public async Task NotifyOwner_ReachesOwnersChat()
{
await using var host = new ChatHost();
var notifier = new TelegramOwnerNotifier(() => host.Chats);
var caller = new A2ACaller("travel-bot", "isolated", host.WorkspaceOf(), "telegram-1001", Updates.AllowedUser.ToString());
await notifier.NotifyAsync(caller, "Alice booked Friday", CancellationToken.None);
Assert.Contains(host.Texts, t => t.Contains("A2A · travel-bot: Alice booked Friday"));
}
[Fact]
public void A2aDisabled_NoEndpoints()
{
var services = new ServiceCollection();
TelegramA2ASetup.Register(services, new TelegramOptions { BotToken = "t", AllowedUserIds = [1] }, new A2AHostOptions { Enabled = false });
Assert.Contains(services, d => d.ServiceType == typeof(IServer) && d.ImplementationType == typeof(NoHttpServer));
Assert.DoesNotContain(services, d => d.ServiceType == typeof(IA2ACallerDirectory));
}
[Fact]
public async Task A2aCommand_ListsMaskedCallersAndTasks()
{
await using var host = new ChatHost(a2a: new A2AHostOptions { Enabled = true });
WriteCallers(host.WorkspaceOf(), """[ { "name": "travel-bot", "key": "secret-key-1234" } ]""");
await host.SendAsync("/a2a");
await host.SettleAsync();
var reply = host.LastReply();
Assert.Contains("travel-bot", reply);
Assert.Contains("…1234", reply);
Assert.DoesNotContain("secret-key-1234", reply);
}
[Fact]
public async Task A2aCommand_DisabledReply()
{
await using var host = new ChatHost();
await host.SendAsync("/a2a");
await host.SettleAsync();
Assert.Contains("A2A is off", host.LastReply());
}
[Fact]
public async Task A2aCommand_ShortKeyFullyMasked_TaskAge()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero));
await using var host = new ChatHost(time: time, a2a: new A2AHostOptions { Enabled = true });
WriteCallers(host.WorkspaceOf(), """[ { "name": "travel-bot", "key": "short-1234" } ]""");
Directory.CreateDirectory(A2ATaskFiles.Dir(host.WorkspaceOf()));
A2ATaskFiles.Write(A2ATaskFiles.PathFor(host.WorkspaceOf(), "task0001abc"), new A2ATaskFile
{
Caller = "travel-bot",
Task = new global::A2A.AgentTask
{
Id = "task0001abc", ContextId = "c1",
Status = new global::A2A.TaskStatus { State = global::A2A.TaskState.Completed, Timestamp = time.GetUtcNow() - TimeSpan.FromMinutes(5) },
},
});
await host.SendAsync("/a2a");
time.Advance(TimeSpan.FromSeconds(1)); // past the input window
await host.SettleAsync();
var reply = host.LastReply();
Assert.Contains("travel-bot (isolated) key …\n", reply + "\n");
Assert.DoesNotContain("1234", reply);
Assert.Contains("travel-bot: Completed, 5m ago (task0001)", reply);
}
/// <summary>The bot's A2A registrations on top of the chat side: no construction cycle, and the shared options.</summary>
private static ChatHost EnabledHost() => new((h, s) =>
{
s.AddLogging();
s.AddSingleton(_ => h.Chats);
s.AddSingleton(_ => h.Settings);
TelegramA2ASetup.Register(s, h.Options, new A2AHostOptions { Enabled = true, MaxHops = 5 });
});
[Fact]
public async Task A2aEnabled_ServicesResolve()
{
await using var host = EnabledHost();
Assert.NotNull(host.Services.GetRequiredService<A2ARequestRouter>());
Assert.NotNull(host.Services.GetRequiredService<A2AServerCache>());
Assert.NotNull(host.Services.GetRequiredService<A2ASessionPool>());
Assert.IsType<TelegramOwnerNotifier>(host.Services.GetRequiredService<IA2AOwnerNotifier>());
Assert.IsType<WorkspaceCallerDirectory>(host.Services.GetRequiredService<IA2ACallerDirectory>());
var options = host.Services.GetRequiredService<IOptions<A2AHostOptions>>().Value;
Assert.True(options.Enabled);
Assert.Equal(5, options.MaxHops);
Assert.Equal(host.Options.Model, options.Model);
Assert.NotNull(options.TimeZoneFor);
}
[Fact]
public async Task A2aSession_GetsBotModelAndOwnersZone()
{
await using var host = EnabledHost();
var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
await host.Zones.SetAsync(Updates.AllowedUser, tokyo, CancellationToken.None);
var pool = host.Services.GetRequiredService<A2ASessionPool>();
var caller = new A2ACaller("travel-bot", "isolated", host.WorkspaceOf(), $"telegram-{Updates.AllowedUser}", Updates.AllowedUser.ToString());
using var lease = await pool.AcquireAsync(caller, "ctx1", "task1", CancellationToken.None);
Assert.Equal(host.Options.Model, lease.Session.Model?.Key);
Assert.Equal(tokyo.Id, lease.Session.TimeZone.Id);
}
[Fact]
public void BindFailure_Classified()
{
Assert.True(TelegramA2ASetup.IsBindFailure(new IOException("Failed to bind", new Microsoft.AspNetCore.Connections.AddressInUseException("in use"))));
Assert.True(TelegramA2ASetup.IsBindFailure(new System.Net.Sockets.SocketException((int)System.Net.Sockets.SocketError.AccessDenied)));
Assert.False(TelegramA2ASetup.IsBindFailure(new IOException("disk full")));
Assert.False(TelegramA2ASetup.IsBindFailure(new InvalidOperationException()));
}
}