feat(telegram): ordered renderer work items and file uploads

This commit is contained in:
EugeneTes
2026-09-26 21:56:05 +02:00
parent b22d0d159e
commit 1eed03e851
6 changed files with 303 additions and 17 deletions

View File

@@ -0,0 +1,85 @@
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Requests;
using Telegram.Bot.Types.Enums;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Tests.Support;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class ChatRendererOrderTests
{
private static (FakeBotClient Bot, TelegramSender Sender, ChatRenderer Renderer) Setup()
{
var bot = new FakeBotClient();
var sender = new TelegramSender(bot, NullLogger<TelegramSender>.Instance);
var options = new TelegramOptions { EditInterval = TimeSpan.FromMilliseconds(20), TypingInterval = TimeSpan.FromSeconds(10) };
return (bot, sender, new ChatRenderer(1001, sender, options, NullLogger.Instance));
}
private static AssistantMessage Answer(string text) =>
new() { Content = [new TextContent(text)], Provider = "faux", Api = "faux", Model = "faux", StopReason = StopReason.Stop };
[Fact]
public async Task RunInOrderAsync_runs_after_the_events_posted_before_it()
{
var (bot, sender, renderer) = Setup();
await using var _ = renderer;
renderer.Post(new AgentSessionEvent.MessageEnd(Answer("first")));
var id = await renderer.RunInOrderAsync(ct => sender.SendTextAsync(1001, "second", cancellationToken: ct)).WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(["first", "second"], bot.Sent.Select(s => s.Text));
Assert.Equal("second", bot.CurrentTexts[id!.Value]);
}
[Fact]
public async Task RunInOrderAsync_propagates_the_work_exception()
{
var (_, _, renderer) = Setup();
await using var __ = renderer;
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => renderer.RunInOrderAsync<int>(_ => throw new InvalidOperationException("boom")));
Assert.Equal("boom", ex.Message);
}
[Fact]
public async Task RunInOrderAsync_after_dispose_is_cancelled()
{
var (_, _, renderer) = Setup();
await renderer.DisposeAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => renderer.RunInOrderAsync(_ => Task.FromResult(1)));
}
[Fact]
public async Task SayAsync_sends_plain_text_cut_to_4096_characters()
{
var (bot, _, renderer) = Setup();
await using var _ = renderer;
await renderer.SayAsync("🎤 " + new string('a', 5000));
var sent = Assert.Single(bot.Sent);
Assert.Equal(4096, sent.Text.Length);
Assert.EndsWith("…", sent.Text, StringComparison.Ordinal);
Assert.Equal(ParseMode.None, sent.ParseMode); // plain text
}
[Fact]
public async Task A_work_item_buries_the_status_so_the_next_tool_update_moves_it_down()
{
var (bot, sender, renderer) = Setup();
await using var _ = renderer;
renderer.Post(new AgentSessionEvent.AgentStart());
await renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
var status = Assert.Single(bot.Sent);
Assert.StartsWith("⚙️", status.Text, StringComparison.Ordinal);
var statusId = bot.CurrentTexts.Single().Key;
await renderer.RunInOrderAsync(ct => sender.SendTextAsync(1001, "file", cancellationToken: ct));
renderer.Post(new AgentSessionEvent.ToolExecutionStart("t1", "send", JsonDocument.Parse("{\"path\":\"a.png\"}").RootElement));
await renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
Assert.Contains(bot.Requests.OfType<DeleteMessageRequest>(), d => d.MessageId == statusId);
Assert.StartsWith("⚙️", bot.Sent[^1].Text, StringComparison.Ordinal);
}
}

View File

@@ -6,6 +6,7 @@ using Telegram.Bot.Exceptions;
using Telegram.Bot.Requests;
using Telegram.Bot.Requests.Abstractions;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
@@ -43,6 +44,21 @@ public sealed class FakeBotClient : ITelegramBotClient
private readonly ConcurrentDictionary<int, string> _texts = new();
public sealed record Upload(string Method, string FileName, byte[] Bytes, string? Caption, ParseMode ParseMode, int MessageId);
private readonly List<Upload> _uploads = [];
/// <summary>Photos and documents sent, with the uploaded bytes (copied inside the request).</summary>
public IReadOnlyList<Upload> Uploads { get { lock (_uploads) return _uploads.ToList(); } }
private Message RecordUpload(string method, ChatId chatId, InputFile file, string? caption, ParseMode parseMode)
{
var input = (InputFileStream)file;
using var copy = new MemoryStream();
input.Content.CopyTo(copy);
var id = Interlocked.Increment(ref _nextMessageId);
lock (_uploads) _uploads.Add(new Upload(method, input.FileName ?? "", copy.ToArray(), caption, parseMode, id));
return new Message { Id = id, Chat = new Chat { Id = chatId.Identifier ?? 0 }, Caption = caption };
}
public FakeBotClient EnqueueUpdates(params Update[] updates)
{
_polls.Enqueue(() => updates);
@@ -102,6 +118,12 @@ public sealed class FakeBotClient : ITelegramBotClient
case GetFileRequest getFile:
result = new TGFile { FileId = getFile.FileId, FileUniqueId = getFile.FileId + "-u", FilePath = $"photos/{getFile.FileId}.jpg", FileSize = 3 };
break;
case SendPhotoRequest photo:
result = RecordUpload("sendPhoto", photo.ChatId, photo.Photo, photo.Caption, photo.ParseMode);
break;
case SendDocumentRequest document:
result = RecordUpload("sendDocument", document.ChatId, document.Document, document.Caption, document.ParseMode);
break;
default:
result = request is GetMeRequest ? new User { Id = BotId, IsBot = true, FirstName = "Fake", Username = "fake_bot" } : true;
break;

View File

@@ -1,6 +1,8 @@
using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Exceptions;
using Telegram.Bot.Requests;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Tests.Support;
@@ -32,4 +34,77 @@ public sealed class TelegramSenderTests
Assert.Single(bot.Reactions);
}
private static (FakeBotClient Bot, TelegramSender Sender, string Path) FileSetup(string name = "chart.png")
{
var bot = new FakeBotClient();
var sender = new TelegramSender(bot, NullLogger<TelegramSender>.Instance) { FileRetryDelays = [TimeSpan.Zero, TimeSpan.Zero] };
var path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "volcanic-upload-" + Guid.NewGuid().ToString("N"), name);
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
System.IO.File.WriteAllBytes(path, [1, 2, 3]);
return (bot, sender, path);
}
[Fact]
public async Task SendFileAsync_uploads_a_photo_with_a_plain_caption()
{
var (bot, sender, path) = FileSetup();
var result = await sender.SendFileAsync(1001, path, asPhoto: true, caption: "<b>Sales</b>");
var upload = Assert.Single(bot.Uploads);
Assert.Equal(("sendPhoto", "chart.png", "<b>Sales</b>", ParseMode.None), (upload.Method, upload.FileName, upload.Caption, upload.ParseMode));
Assert.Equal(new byte[] { 1, 2, 3 }, upload.Bytes);
Assert.Equal(new TelegramSender.FileSendResult(upload.MessageId, null, false), result);
}
[Fact]
public async Task SendFileAsync_sends_a_document()
{
var (bot, sender, path) = FileSetup("report.pdf");
var result = await sender.SendFileAsync(1001, path, asPhoto: false, caption: null);
Assert.Equal("sendDocument", Assert.Single(bot.Uploads).Method);
Assert.NotNull(result.MessageId);
}
[Fact]
public async Task SendFileAsync_retries_a_network_failure_twice()
{
var (bot, sender, path) = FileSetup("report.pdf");
var failures = 2;
bot.OnSend = r => { if (r is SendDocumentRequest && failures-- > 0) throw FakeBotClient.NetworkDown(); };
var result = await sender.SendFileAsync(1001, path, asPhoto: false, caption: null);
Assert.NotNull(result.MessageId);
Assert.Equal(3, bot.Requests.OfType<SendDocumentRequest>().Count());
}
[Fact]
public async Task SendFileAsync_gives_up_after_two_retries()
{
var (bot, sender, path) = FileSetup("report.pdf");
bot.OnSend = r => { if (r is SendDocumentRequest) throw FakeBotClient.NetworkDown(); };
var result = await sender.SendFileAsync(1001, path, asPhoto: false, caption: null);
Assert.Null(result.MessageId);
Assert.NotNull(result.Error);
Assert.False(result.PhotoRejected);
Assert.Equal(3, bot.Requests.OfType<SendDocumentRequest>().Count());
}
[Fact]
public async Task A_400_on_sendPhoto_is_reported_as_PhotoRejected()
{
var (bot, sender, path) = FileSetup();
bot.OnSend = r => { if (r is SendPhotoRequest) throw new ApiRequestException("Bad Request: PHOTO_INVALID_DIMENSIONS", 400); };
var result = await sender.SendFileAsync(1001, path, asPhoto: true, caption: null);
Assert.True(result.PhotoRejected);
Assert.Contains("PHOTO_INVALID_DIMENSIONS", result.Error, StringComparison.Ordinal);
Assert.Single(bot.Requests.OfType<SendPhotoRequest>()); // an API error is not retried
}
[Fact]
public async Task A_400_on_sendDocument_is_an_error_not_a_rejected_photo()
{
var (bot, sender, path) = FileSetup("report.pdf");
bot.OnSend = r => { if (r is SendDocumentRequest) throw new ApiRequestException("Bad Request: file is empty", 400); };
var result = await sender.SendFileAsync(1001, path, asPhoto: false, caption: null);
Assert.Equal(new TelegramSender.FileSendResult(null, "Bad Request: file is empty", false), result);
}
}