From c08885c2d95a4b154f429a4237f2eccbac676e21 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 28 Jul 2026 00:50:04 -0400 Subject: [PATCH] Test agent TTS routing in settings layer Signed-off-by: John Tennant --- .../src/huddle/agent_tts_routing_tests.rs | 26 +++++++++++++++ desktop/src-tauri/src/huddle/mod.rs | 33 ++++++++++++++----- 2 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs diff --git a/desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs b/desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs new file mode 100644 index 000000000..c6ce68685 --- /dev/null +++ b/desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs @@ -0,0 +1,26 @@ +use super::{enqueue_agent_tts_text, normalize_agent_tts_text, MAX_TTS_TEXT_LEN}; + +#[tokio::test] +async fn assistant_plain_text_routes_unchanged_into_voice_pipeline_boundary() { + let (sender, receiver) = std::sync::mpsc::channel(); + let text = "A newly submitted assistant reply.".to_string(); + + enqueue_agent_tts_text(text.clone(), move |queued| { + sender.send(queued).map_err(|error| error.to_string()) + }) + .await + .expect("route assistant text"); + + assert_eq!(receiver.recv().expect("queued text"), text); +} + +#[test] +fn assistant_text_truncation_is_unicode_safe_before_voice_routing() { + let input = "🦀".repeat(MAX_TTS_TEXT_LEN + 1); + let output = normalize_agent_tts_text(input); + assert_eq!( + output.chars().count(), + MAX_TTS_TEXT_LEN + "... message truncated.".chars().count() + ); + assert!(output.ends_with("... message truncated.")); +} diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 7eaa5add4..e27491650 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -825,6 +825,25 @@ pub fn get_model_status(_state: State<'_, AppState>) -> Result String { + if text.chars().count() > MAX_TTS_TEXT_LEN { + let mut truncated: String = text.chars().take(MAX_TTS_TEXT_LEN).collect(); + truncated.push_str("... message truncated."); + truncated + } else { + text + } +} + +async fn enqueue_agent_tts_text(text: String, enqueue: F) -> Result<(), String> +where + F: FnOnce(String) -> Result<(), String> + Send + 'static, +{ + tokio::task::spawn_blocking(move || enqueue(text)) + .await + .map_err(|error| format!("TTS enqueue task failed: {error}"))? +} + /// Called by the WebView when it receives an incoming agent kind:9 message. /// Lazily starts the TTS pipeline if models are ready but the pipeline hasn't /// been created yet (e.g. models finished downloading after huddle started). @@ -834,13 +853,7 @@ const MAX_TTS_TEXT_LEN: usize = 2000; pub async fn speak_agent_message(text: String, state: State<'_, AppState>) -> Result<(), String> { // Truncate oversized messages — agents shouldn't monologue in a voice huddle. // Use char count (not byte length) to avoid panicking on multi-byte UTF-8. - let text = if text.chars().count() > MAX_TTS_TEXT_LEN { - let mut truncated: String = text.chars().take(MAX_TTS_TEXT_LEN).collect(); - truncated.push_str("... message truncated."); - truncated - } else { - text - }; + let text = normalize_agent_tts_text(text); let needs_pipeline = { let hs = state.huddle()?; @@ -869,15 +882,17 @@ pub async fn speak_agent_message(text: String, state: State<'_, AppState>) -> Re let Some(sender) = sender else { return Ok(()); }; - tokio::task::spawn_blocking(move || { + enqueue_agent_tts_text(text, move |text| { sender .send(text) .map_err(|error| format!("TTS queue closed while waiting to enqueue: {error}")) }) .await - .map_err(|error| format!("TTS enqueue task failed: {error}"))? } +#[cfg(test)] +mod agent_tts_routing_tests; + /// Add an agent to the active huddle. /// /// Steps: