Files
EugeneTes c3f1bbd463 feat(telegram): react to steering messages, discard the inbox on /new and /resume
A flushed batch that joins a running request gets a šŸ‘€ reaction on each message. /new and a successful /resume drop buffered and in-flight inbox parts. A forwarded message never answers a pending TelegramUi question. /help mentions replies and quotes.
2026-09-26 20:06:43 +02:00

314 lines
12 KiB
C#

using Telegram.Bot.Requests;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Tests.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class ChatTests
{
[Fact]
public async Task A_reply_is_sent_once_when_complete()
{
await using var host = new ChatHost();
host.Faux.Enqueue(FauxTurn.Say("Hello **there**, how can I help?"));
await host.SendAsync("hi");
await host.SettleAsync();
var reply = Assert.Single(host.Bot.Sent, s => !s.Text.StartsWith(Working, StringComparison.Ordinal));
Assert.Equal(ParseMode.Html, reply.ParseMode);
Assert.Equal("Hello <b>there</b>, how can I help?", host.Bot.CurrentTexts.Values.Single());
Assert.Empty(host.Bot.Edits); // not streamed
Assert.Matches(@"^\[\w{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2} [^\]]+\]\nhi$", host.Faux.Requests.Single().Messages.Last().Text); // the timestamp line comes first
}
[Fact]
public async Task Messages_from_users_outside_the_allow_list_are_ignored()
{
await using var host = new ChatHost();
await host.SendAsync("let me in", userId: Updates.Stranger);
Assert.Empty(host.Bot.Requests);
Assert.Null(host.Chats.Find(Updates.Stranger));
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task Group_chats_are_ignored()
{
await using var host = new ChatHost();
await host.Router.RouteAsync(Updates.Text("hi all", chatId: -100, chatType: ChatType.Group), CancellationToken.None);
Assert.Empty(host.Bot.Requests);
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task A_long_reply_is_split_across_messages()
{
await using var host = new ChatHost();
var words = string.Join(" ", Enumerable.Range(0, 2000).Select(i => $"word{i}"));
host.Faux.Enqueue(FauxTurn.Say(words));
await host.SendAsync("talk a lot");
await host.SettleAsync();
var texts = host.Texts.ToList();
Assert.True(texts.Count >= 4, $"expected several messages, got {texts.Count}");
Assert.All(texts, t => Assert.True(t.Length <= 4096));
Assert.Equal(words, string.Join(" ", texts));
}
private const string Working = "āš™ļø <b>Working...</b>";
[Fact]
public async Task A_run_shows_a_working_message_that_is_deleted_when_it_settles()
{
await using var host = new ChatHost();
host.Faux.Enqueue(FauxTurn.Tool("ls", new { path = "." }), FauxTurn.Say("Nothing here."));
await host.SendAsync("what is here?");
await host.SettleAsync();
Assert.Equal(["Nothing here."], host.Texts);
Assert.Contains(host.Bot.Sent, s => s.Text.StartsWith(Working, StringComparison.Ordinal));
Assert.Contains(host.Bot.Requests, r => r is DeleteMessageRequest);
}
[Fact]
public async Task The_working_message_lists_the_last_five_tool_calls()
{
await using var host = new ChatHost();
for (var i = 1; i <= 7; i++) host.Faux.Enqueue(FauxTurn.Tool("ls", new { path = $"dir{i}" }));
host.Faux.Enqueue(FauxTurn.Say("Done.").WithDelay(TimeSpan.FromSeconds(30)));
await host.SendAsync("look everywhere");
await Wait.UntilAsync(() => host.Faux.Requests.Count == 8, "the last model call");
await Wait.UntilAsync(() => Status(host) is { } s && s.Contains("dir7", StringComparison.Ordinal) && !s.Contains('ā–¶'), "the status with all seven calls finished");
var lines = Status(host)!.Split('\n');
Assert.Equal(Working, lines[0]);
// The directories do not exist, so every call failed.
Assert.Equal(Enumerable.Range(3, 5).Select(i => $"āœ— <b>ls</b>: <code>dir{i}</code>"), lines[1..]);
await host.SendAsync("/stop");
await host.SettleAsync();
Assert.Null(Status(host));
}
[Fact]
public async Task A_running_tool_is_marked_until_it_finishes()
{
await using var host = new ChatHost();
var gate = new TaskCompletionSource();
host.Faux.Enqueue(FauxTurn.Tool("bash", new { command = "sleep 1" }), FauxTurn.Say("ok"));
host.Bot.OnSend = r =>
{
// Hold the first status edit that shows the running call until the test has seen it.
if (r is EditMessageTextRequest { Text: { } t } && t.Contains("ā–¶", StringComparison.Ordinal)) gate.Task.Wait(5000);
};
await host.SendAsync("sleep");
await Wait.UntilAsync(() => host.Bot.Edits.Any(e => e.Text!.Contains("ā–¶ <b>bash</b>: <code>sleep 1</code>", StringComparison.Ordinal)), "the running line");
gate.SetResult();
await host.SettleAsync();
Assert.Equal(["ok"], host.Texts);
}
[Fact]
public async Task The_working_message_moves_below_new_reply_text()
{
await using var host = new ChatHost();
host.Faux.Enqueue(FauxTurn.Say("Let me look.").WithTool("ls", new { }), FauxTurn.Say("Done.").WithDelay(TimeSpan.FromSeconds(30)));
await host.SendAsync("look");
await Wait.UntilAsync(() => host.Faux.Requests.Count == 2, "the second model call");
await Wait.UntilAsync(() => Status(host) is { } s && s.Contains('āœ“'), "the finished ls");
var inOrder = host.Bot.CurrentTexts.OrderBy(kv => kv.Key).Select(kv => kv.Value).ToList();
Assert.Equal("Let me look.", inOrder[0]);
Assert.StartsWith(Working, inOrder[^1]);
Assert.Equal(2, inOrder.Count);
await host.SendAsync("/stop");
await host.SettleAsync();
}
private static string? Status(ChatHost host) => host.Texts.FirstOrDefault(t => t.StartsWith(Working, StringComparison.Ordinal));
[Fact]
public async Task Stop_aborts_a_running_prompt()
{
await using var host = new ChatHost();
host.Faux.Enqueue(FauxTurn.Say("too late").WithDelay(TimeSpan.FromSeconds(30)));
await host.SendAsync("take your time");
var chat = await host.ChatAsync();
await Wait.UntilAsync(() => chat.Session.IsStreaming, "the run to start");
await host.SendAsync("/stop");
await host.SettleAsync();
Assert.Contains("ā¹ Stopped.", host.Texts);
Assert.Null(Status(host));
Assert.DoesNotContain(host.Texts, t => t.Contains("too late", StringComparison.Ordinal));
}
[Fact]
public async Task A_message_while_running_steers_instead_of_failing()
{
await using var host = new ChatHost();
host.Faux.Enqueue(FauxTurn.Tool("ls", new { }).WithDelay(TimeSpan.FromMilliseconds(300)), FauxTurn.Say("Adjusted."));
await host.SendAsync("list files");
var chat = await host.ChatAsync();
await Wait.UntilAsync(() => chat.Session.IsStreaming, "the run to start");
await host.SendAsync("actually, also say hi");
await host.SettleAsync();
Assert.DoesNotContain(host.Texts, t => t.Contains("failed", StringComparison.OrdinalIgnoreCase));
Assert.Contains(host.Faux.Requests.Last().Messages, m => m.Text.EndsWith("]\nactually, also say hi", StringComparison.Ordinal));
}
[Fact]
public async Task New_starts_a_fresh_session()
{
await using var host = new ChatHost();
host.Faux.Enqueue(FauxTurn.Say("one"));
await host.SendAsync("first");
await host.SettleAsync();
var chat = await host.ChatAsync();
var firstId = chat.Session.SessionId;
await host.SendAsync("/new");
Assert.NotEqual(firstId, chat.Session.SessionId);
Assert.Empty(chat.Session.Messages);
Assert.Contains("šŸ†• New conversation.", host.Texts);
}
[Fact]
public async Task Status_reports_the_model()
{
await using var host = new ChatHost();
await host.SendAsync("/status@fake_bot");
Assert.Contains(host.Texts, t => t.Contains(FauxProvider.ModelKey, StringComparison.Ordinal));
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task Help_mentions_replies()
{
await using var host = new ChatHost();
await host.SendAsync("/help");
Assert.Contains(host.Texts, t => t.Contains("Reply to one of my messages", StringComparison.Ordinal));
}
[Fact]
public async Task Unknown_commands_go_to_the_session()
{
await using var host = new ChatHost();
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.SendAsync("/whatever please");
await host.SettleAsync();
Assert.Equal("/whatever please", host.Faux.Requests.Single().Messages.Last().Text);
}
[Fact]
public async Task Select_is_answered_with_an_inline_keyboard_button()
{
await using var host = new ChatHost();
await host.SendAsync("/status"); // creates the chat
var chat = await host.ChatAsync();
var answer = chat.Ui.SelectAsync("Pick one", ["red", "green"], null, CancellationToken.None);
await Wait.UntilAsync(() => host.Bot.Sent.Any(s => s.ReplyMarkup is InlineKeyboardMarkup), "the keyboard");
var keyboard = (InlineKeyboardMarkup)host.Bot.Sent.Last(s => s.ReplyMarkup is InlineKeyboardMarkup).ReplyMarkup!;
var green = keyboard.InlineKeyboard.SelectMany(row => row).Single(b => b.Text == "green");
await host.Router.RouteAsync(Updates.Callback(green.CallbackData!, Updates.AllowedUser), CancellationToken.None);
Assert.Equal("green", await answer.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Contains(host.Bot.Requests, r => r is AnswerCallbackQueryRequest);
Assert.Contains("Pick one\n→ green", host.Texts);
}
[Fact]
public async Task Input_is_answered_by_the_next_plain_message()
{
await using var host = new ChatHost();
await host.SendAsync("/status");
var chat = await host.ChatAsync();
var answer = chat.Ui.InputAsync("Your name?", "name", null, CancellationToken.None);
await Wait.UntilAsync(() => chat.Ui.IsWaitingForInput, "the question");
await host.SendAsync("Ada");
Assert.Equal("Ada", await answer.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Empty(host.Faux.Requests); // the answer is not a prompt
}
[Theory]
[InlineData(null)]
[InlineData("what is this?")]
public async Task A_photo_does_not_answer_a_pending_input(string? caption)
{
await using var host = new ChatHost();
await host.SendAsync("/status");
var chat = await host.ChatAsync();
var answer = chat.Ui.InputAsync("Your name?", "name", null, CancellationToken.None);
await Wait.UntilAsync(() => chat.Ui.IsWaitingForInput, "the question");
await host.Router.RouteAsync(Updates.Photo(caption), CancellationToken.None);
Assert.True(chat.Ui.IsWaitingForInput);
Assert.False(answer.IsCompleted);
await host.SendAsync("Ada");
Assert.Equal("Ada", await answer.WaitAsync(TimeSpan.FromSeconds(5)));
}
[Fact]
public async Task Buttons_from_strangers_are_ignored()
{
await using var host = new ChatHost();
await host.SendAsync("/status");
var chat = await host.ChatAsync();
var answer = chat.Ui.SelectAsync("Pick", ["a"], new(TimeSpan.FromMilliseconds(300)), CancellationToken.None);
await Wait.UntilAsync(() => host.Bot.Sent.Any(s => s.ReplyMarkup is InlineKeyboardMarkup), "the keyboard");
var data = ((InlineKeyboardMarkup)host.Bot.Sent.Last().ReplyMarkup!).InlineKeyboard.First().First().CallbackData!;
await host.Router.RouteAsync(Updates.Callback(data, Updates.AllowedUser, userId: Updates.Stranger), CancellationToken.None);
Assert.Null(await answer.WaitAsync(TimeSpan.FromSeconds(5)));
}
[Fact]
public async Task Telegram_send_failures_do_not_break_rendering()
{
await using var host = new ChatHost();
var failures = 0;
host.Bot.OnSend = r =>
{
if (r is SendChatActionRequest && Interlocked.Increment(ref failures) < 100) throw FakeBotClient.NetworkDown();
};
host.Faux.Enqueue(FauxTurn.Say("still here"));
await host.SendAsync("hello?");
await host.SettleAsync();
Assert.Contains("still here", host.Texts);
}
}