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