Files
VolcanicAgenticCore/tests/Volcanic.AgenticCore.Telegram.Tests/VoiceChatTests.cs

274 lines
12 KiB
C#

using Microsoft.Extensions.Time.Testing;
using Telegram.Bot.Requests;
using Volcanic.AgenticCore.Telegram.Speech;
using Volcanic.AgenticCore.Telegram.Tests.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class VoiceChatTests
{
private const string NotConfigured = "Voice messages need speech-to-text: set OPENAI_API_KEY (or Telegram:Speech:ApiKey) on the bot host.";
private static string Prompt(ChatHost host) => host.Faux.Requests.Single().Messages.Last().Text;
[Fact]
public async Task A_voice_note_is_echoed_before_the_answer_and_prompted()
{
var stt = new FakeSpeechToText("remind me to call Ada");
await using var host = new ChatHost(speech: stt);
host.Bot.Files["voice"] = [1, 2, 3];
host.Faux.Enqueue(FauxTurn.Say("Will do."));
await host.RouteAsync(Updates.VoiceMessage());
await host.SettleAsync();
var texts = host.Texts.ToList();
Assert.InRange(texts.IndexOf("🎤 remind me to call Ada"), 0, texts.IndexOf("Will do.") - 1);
Assert.EndsWith("\n[Voice message] remind me to call Ada", Prompt(host), StringComparison.Ordinal);
var call = Assert.Single(stt.Calls);
Assert.Equal(("voice.ogg", "audio/ogg"), (call.FileName, call.MimeType));
Assert.Equal(new byte[] { 1, 2, 3 }, call.Audio);
}
[Fact]
public async Task A_long_transcript_echo_is_cut_to_1024_characters()
{
var stt = new FakeSpeechToText(new string('a', 5000));
await using var host = new ChatHost(speech: stt);
host.Bot.Files["voice"] = [1];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.RouteAsync(Updates.VoiceMessage());
await host.SettleAsync();
var echo = host.Bot.Sent.Single(s => s.Text.StartsWith("🎤", StringComparison.Ordinal)).Text;
Assert.Equal(1024, echo.Length);
Assert.EndsWith("…", echo, StringComparison.Ordinal);
Assert.Contains(new string('a', 5000), Prompt(host), StringComparison.Ordinal); // the model gets all of it
}
[Fact]
public async Task Without_speech_to_text_a_voice_note_gets_the_setup_notice_and_no_run()
{
await using var host = new ChatHost();
host.Bot.Files["voice"] = [1];
await host.RouteAsync(Updates.VoiceMessage());
await host.SettleAsync();
Assert.Contains(NotConfigured, host.Texts);
Assert.Empty(host.Faux.Requests);
Assert.Empty(host.Bot.Requests.OfType<GetFileRequest>());
}
[Fact]
public async Task Without_speech_to_text_a_captioned_voice_note_still_prompts_the_caption()
{
await using var host = new ChatHost();
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.RouteAsync(Updates.VoiceMessage(caption: "listen to this"));
await host.SettleAsync();
Assert.Contains(NotConfigured, host.Texts);
Assert.EndsWith("\nlisten to this", Prompt(host), StringComparison.Ordinal);
}
[Fact]
public async Task A_transcription_failure_gets_a_notice_and_no_run()
{
var stt = new FakeSpeechToText { Failure = new SpeechToTextException("OpenAI transcription failed: HTTP 500 oops") };
await using var host = new ChatHost(speech: stt);
host.Bot.Files["voice"] = [1];
await host.RouteAsync(Updates.VoiceMessage());
await host.SettleAsync();
Assert.Contains("Could not transcribe the voice message: OpenAI transcription failed: HTTP 500 oops", host.Texts);
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task A_transcription_timeout_gets_a_notice_and_no_run()
{
// HttpClient.Timeout surfaces as a TaskCanceledException although nobody cancelled the message.
var stt = new FakeSpeechToText { Failure = new TaskCanceledException("timeout", new TimeoutException()) };
await using var host = new ChatHost(speech: stt);
host.Bot.Files["voice"] = [1];
await host.RouteAsync(Updates.VoiceMessage());
await host.SettleAsync();
Assert.Contains("Could not transcribe the voice message: timeout", host.Texts);
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task An_audio_transcription_timeout_still_prompts_the_saved_file()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
var stt = new FakeSpeechToText { Failure = new TaskCanceledException("timeout", new TimeoutException()) };
await using var host = new ChatHost(time: time, speech: stt);
host.Bot.Files["audio"] = [4, 5, 6];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.RouteAsync(Updates.AudioMessage("talk.mp3", "audio/mpeg", 3));
await MessageContextChatTests.FlushAsync(host, time, 1);
await host.SettleAsync();
Assert.Contains("Could not transcribe the voice message: timeout", host.Texts);
Assert.EndsWith("[File received: inbox/2026-09-26-talk.mp3 (audio/mpeg, 3 bytes)]", Prompt(host), StringComparison.Ordinal);
}
[Fact]
public async Task An_empty_transcript_gets_a_notice_and_no_run()
{
await using var host = new ChatHost(speech: new FakeSpeechToText(" "));
host.Bot.Files["voice"] = [1];
await host.RouteAsync(Updates.VoiceMessage());
await host.SettleAsync();
Assert.Contains("I couldn't make out any words in that voice message.", host.Texts);
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task A_voice_note_over_20_MB_gets_the_size_notice_and_no_download()
{
var stt = new FakeSpeechToText();
await using var host = new ChatHost(speech: stt);
await host.RouteAsync(Updates.VoiceMessage(size: 21L * 1024 * 1024, caption: "about this"));
await host.SettleAsync();
Assert.Contains("That file is 21.0 MB; Telegram lets bots download at most 20 MB. Share a link or a smaller file.", host.Texts);
Assert.Empty(host.Faux.Requests);
Assert.Empty(host.Bot.Requests.OfType<GetFileRequest>());
Assert.Empty(stt.Calls);
}
[Fact]
public async Task A_failed_voice_download_gets_a_notice_and_no_run()
{
var stt = new FakeSpeechToText();
await using var host = new ChatHost(speech: stt);
host.Bot.Files["voice"] = [1];
host.Bot.FailDownloads = true;
await host.RouteAsync(Updates.VoiceMessage(caption: "about this"));
await host.SettleAsync();
Assert.Contains("I could not download that file.", host.Texts);
Assert.Empty(host.Faux.Requests);
Assert.Empty(stt.Calls);
}
[Fact]
public async Task An_audio_file_is_saved_and_transcribed()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
var stt = new FakeSpeechToText("welcome to the show");
await using var host = new ChatHost(time: time, speech: stt);
host.Bot.Files["audio"] = [4, 5, 6];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.RouteAsync(Updates.AudioMessage("talk.mp3", "audio/mpeg", 3));
await MessageContextChatTests.FlushAsync(host, time, 1);
await host.SettleAsync();
Assert.EndsWith("\n[File received: inbox/2026-09-26-talk.mp3 (audio/mpeg, 3 bytes)]\n\n[Voice message] welcome to the show", Prompt(host), StringComparison.Ordinal);
Assert.Equal(("audio.mp3", "audio/mpeg"), (stt.Calls.Single().FileName, stt.Calls.Single().MimeType));
Assert.Equal(new byte[] { 4, 5, 6 }, stt.Calls.Single().Audio);
Assert.True(File.Exists(Path.Combine(host.WorkspaceOf(), "inbox", "2026-09-26-talk.mp3")));
Assert.Single(host.Bot.Requests.OfType<GetFileRequest>()); // downloaded once, transcribed from the saved file
}
[Theory]
[InlineData("Песня.mp3", "audio/mpeg", "audio.mp3")]
[InlineData("talk.M4A", "audio/mp4", "audio.m4a")]
[InlineData(null, "audio/mp4", "audio.m4a")]
[InlineData(null, "audio/ogg", "audio.ogg")]
[InlineData("memo.oga", "audio/ogg", "audio.ogg")]
[InlineData("noext", null, "audio.mp3")]
public void Audio_goes_to_speech_to_text_under_an_ascii_name_with_a_known_extension(string? fileName, string? mime, string expected) =>
Assert.Equal(expected, Volcanic.AgenticCore.Telegram.Inbound.InboundMedia.SpeechFileName(fileName, mime));
[Fact]
public async Task Without_speech_to_text_an_audio_file_is_still_saved_and_prompted()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
await using var host = new ChatHost(time: time);
host.Bot.Files["audio"] = [4, 5, 6];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.RouteAsync(Updates.AudioMessage("talk.mp3", "audio/mpeg", 3));
await MessageContextChatTests.FlushAsync(host, time, 1);
await host.SettleAsync();
Assert.Contains(NotConfigured, host.Texts);
Assert.EndsWith("[File received: inbox/2026-09-26-talk.mp3 (audio/mpeg, 3 bytes)]", Prompt(host), StringComparison.Ordinal);
}
[Fact]
public async Task A_voice_note_and_a_text_right_after_it_are_one_prompt_in_order()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
var stt = new FakeSpeechToText("first the voice") { Gate = new TaskCompletionSource() };
await using var host = new ChatHost(time: time, speech: stt);
host.Bot.Files["voice"] = [1];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.RouteAsync(Updates.VoiceMessage());
await host.SendAsync("then the text");
var chat = await host.ChatAsync();
await Wait.UntilAsync(() => stt.Calls.Count == 1 && chat.Inbox.Buffered == 1 && chat.Inbox.OpenLeases == 1, "the text composed, the voice held");
time.Advance(host.Options.InputDebounce * 10); // the debounce window elapses while the voice lease is still open
await Task.Delay(50);
Assert.Empty(host.Faux.Requests);
stt.Gate.SetResult();
await MessageContextChatTests.FlushAsync(host, time, 2);
await host.SettleAsync();
var prompt = Prompt(host);
Assert.InRange(prompt.IndexOf("[Voice message] first the voice", StringComparison.Ordinal), 0, prompt.IndexOf("then the text", StringComparison.Ordinal) - 1);
}
[Fact]
public async Task Stop_is_handled_during_a_slow_transcription()
{
var stt = new FakeSpeechToText("late") { Gate = new TaskCompletionSource() };
await using var host = new ChatHost(speech: stt);
host.Bot.Files["voice"] = [1];
host.Faux.Enqueue(FauxTurn.Say("too late").WithDelay(TimeSpan.FromSeconds(30)), FauxTurn.Say("Heard you."));
await host.SendAsync("take your time");
var chat = await host.ChatAsync();
await Wait.UntilAsync(() => chat.Session.IsStreaming, "the run to start");
await host.RouteAsync(Updates.VoiceMessage()).WaitAsync(TimeSpan.FromSeconds(2)); // returns while transcription hangs
await Wait.UntilAsync(() => stt.Calls.Count == 1, "the transcription to start");
await host.SendAsync("/stop").WaitAsync(TimeSpan.FromSeconds(2));
await Wait.UntilAsync(() => !chat.Session.IsStreaming, "the run to stop");
Assert.False(stt.Gate.Task.IsCompleted);
stt.Gate.SetResult();
await host.SettleAsync();
Assert.Contains("⏹ Stopped.", host.Texts);
}
[Fact]
public async Task A_voice_note_does_not_answer_a_pending_question()
{
await using var host = new ChatHost(speech: new FakeSpeechToText("Ada Lovelace"));
host.Bot.Files["voice"] = [1];
host.Faux.Enqueue(FauxTurn.Say("ok"));
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.RouteAsync(Updates.VoiceMessage());
await host.SettleAsync();
Assert.True(chat.Ui.IsWaitingForInput);
Assert.False(answer.IsCompleted);
Assert.EndsWith("[Voice message] Ada Lovelace", Prompt(host), StringComparison.Ordinal);
await host.SendAsync("Ada");
Assert.Equal("Ada", await answer.WaitAsync(TimeSpan.FromSeconds(5)));
}
}