forked from admin/VolcanicAgenticCore
feat(telegram): ordered renderer work items and file uploads
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user