155 lines
7.2 KiB
C#
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()));
|
|
}
|
|
}
|