fix(telegram): clean exit on A2A bind failure, log listen URLs, /a2a polish

This commit is contained in:
EugeneTes
2026-10-07 14:02:49 +00:00
parent a3aee939d4
commit 372c600503
6 changed files with 155 additions and 24 deletions

View File

@@ -1,8 +1,15 @@
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;
@@ -72,4 +79,76 @@ public class A2AHostTests
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()));
}
}

View File

@@ -28,6 +28,9 @@ public sealed class ChatHost : IAsyncDisposable
public FakeBotClient Bot { get; } = new();
public FauxProvider Faux { get; } = new();
public TelegramOptions Options { get; }
/// <summary>The services built from <c>configure</c> and the core (sessions, plus whatever the test added).</summary>
public IServiceProvider Services => _services;
public ChatRegistry Chats { get; }
public UpdateRouter Router { get; }
private readonly ServiceProvider _services;
@@ -116,7 +119,8 @@ public sealed class ChatHost : IAsyncDisposable
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), sender, Composer, NullLogger<UpdateRouter>.Instance, Impersonation, Feed);
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);
}
private readonly TriggerDispatcher? _dispatcher;