Files
Playa/tests/Volcanic.AgenticCore.Telegram.Tests/ChatRendererOrderTests.cs
EugeneTes 6ab98a8155 fix(telegram): address review findings
- A speech or download timeout (TaskCanceledException nobody asked for) is a failure
  with the user notice, not a silent shutdown; OpenAiSpeechToText maps it to
  "the request timed out."; UpdateRouter only treats the caller's cancellation as shutdown.
- send: WorkspacePaths resolves the real path component by component (realpath),
  closing the directory-link-inside-target bypass; `.` reports a directory.
- Image documents are attached inline only when their magic bytes are JPEG/PNG/GIF/WebP,
  under the detected type.
- Audio goes to STT as an ASCII audio.<ext> name.
- MCP Browser section mentions send only when it is active.
- RunInOrderAsync takes a cancellation token; queued work is skipped once disposal gives up;
  SendReplyAsync stays private behind ReplyInOrderAsync.
- Speech HttpClient handler recycles pooled connections; BuiltInTools.Send();
  IExtensionHost.Channel is abstract; WorkspacePaths in its own file.
- Tests: timeouts, symlink chains (Linux), sniffing, channel delivery failures,
  queued-work cancellation, gated voice ordering. Spec and docs updated.
2026-09-26 22:35:20 +02:00

137 lines
5.9 KiB
C#

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 RunInOrderAsync_cancelled_while_queued_returns_at_once_and_skips_the_work()
{
var (_, _, renderer) = Setup();
await using var _ = renderer;
var gate = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
var slow = renderer.RunInOrderAsync(ct => gate.Task.WaitAsync(ct)); // e.g. a big upload with retries
var ran = false;
using var cts = new CancellationTokenSource();
var queued = renderer.RunInOrderAsync(_ => { ran = true; return Task.FromResult(2); }, cts.Token);
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => queued.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.False(slow.IsCompleted);
gate.SetResult(1);
Assert.Equal(1, await slow.WaitAsync(TimeSpan.FromSeconds(5)));
await renderer.RunInOrderAsync(_ => Task.FromResult(0)).WaitAsync(TimeSpan.FromSeconds(5)); // the loop moved past it
Assert.False(ran);
}
[Fact]
public async Task RunInOrderAsync_with_a_cancelled_token_never_queues()
{
var (_, _, renderer) = Setup();
await using var _ = renderer;
var ran = false;
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
renderer.RunInOrderAsync(_ => { ran = true; return Task.FromResult(1); }, new CancellationToken(canceled: true)));
Assert.False(ran);
}
[Fact]
public async Task A_work_item_still_queued_when_disposal_gives_up_waiting_is_cancelled()
{
var (_, _, renderer) = Setup();
var stuck = renderer.RunInOrderAsync(async ct =>
{
await Task.Delay(Timeout.Infinite, ct);
return 1;
});
var ran = false;
var queued = renderer.RunInOrderAsync(_ => { ran = true; return Task.FromResult(2); });
await renderer.DisposeAsync(); // drains for up to 5 s, then cancels the loop
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => queued.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.False(ran);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => stuck);
}
[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);
}
}