forked from admin/VolcanicAgenticCore
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.
314 lines
12 KiB
C#
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);
|
|
}
|
|
}
|