diff --git a/gateway/run.py b/gateway/run.py index 1babd79d285b..3cc5ad5b1bb5 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -15755,11 +15755,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Use SimpleNamespace as raw_message so _get_guild_id() can extract # guild_id and _send_voice_reply() plays audio in the voice channel. from types import SimpleNamespace + # Resolve the bound text channel's channel_prompt so voice input gets + # the same per-channel context as typed messages (#50149). + channel_prompt: Optional[str] = None + resolver = getattr(adapter, "_resolve_channel_prompt", None) + if callable(resolver): + try: + resolved = resolver(str(text_ch_id)) + channel_prompt = resolved if isinstance(resolved, str) else None + except Exception: + channel_prompt = None event = MessageEvent( source=source, text=transcript, message_type=MessageType.VOICE, raw_message=SimpleNamespace(guild_id=guild_id, guild=None), + channel_prompt=channel_prompt, ) await adapter.handle_message(event) @@ -18012,6 +18023,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew result = await asyncio.to_thread(transcribe_audio, path) if result["success"]: transcript = result["transcript"] + # Speech-to-text can return success=True with an empty or + # whitespace-only transcript on silence, cut-off, or + # inaudible audio. Emitting empty quotes ('""') makes the + # agent reply to nothing and can loop, so that case gets a + # clear sentinel note instead (#41603). + if not (transcript or "").strip(): + enriched_parts.append( + "[The user sent a voice message but it came through " + "empty or inaudible — speech-to-text returned no " + "words. Do not guess at the content; ask the user " + "to resend or type it out.]" + ) + continue successful_transcripts.append(transcript) # Pass the transcript through as a plain quoted line. The # earlier wording ("The user sent a voice message~ Here's diff --git a/tests/gateway/test_stt_config.py b/tests/gateway/test_stt_config.py index 3a0b54ea69a6..46326c8cbf87 100644 --- a/tests/gateway/test_stt_config.py +++ b/tests/gateway/test_stt_config.py @@ -181,6 +181,30 @@ async def test_enrich_message_with_transcription_handles_missing_transcription_m assert transcripts == [] +@pytest.mark.asyncio +async def test_enrich_message_with_transcription_guards_empty_transcript(): + """success=True with an empty/whitespace transcript must not emit empty + quotes — it gets a sentinel note and is excluded from transcripts (#41603).""" + from gateway.run import GatewayRunner + + runner = GatewayRunner.__new__(GatewayRunner) + runner.config = GatewayConfig(stt_enabled=True) + runner._has_setup_skill = lambda: False + + with patch( + "tools.transcription_tools.transcribe_audio", + return_value={"success": True, "transcript": " \n\t", "provider": "local_command"}, + ): + result, transcripts = await runner._enrich_message_with_transcription( + "caption", + ["/tmp/voice.ogg"], + ) + + assert "empty or inaudible" in result + assert '""' not in result + assert transcripts == [] + + @pytest.mark.asyncio async def test_prepare_inbound_message_text_transcribes_queued_voice_event(): from gateway.run import GatewayRunner diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index 7fc4a3bc3b44..11188f5ccea0 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -1030,6 +1030,39 @@ class TestVoiceChannelCommands: assert event.source.chat_id == "123" assert event.source.chat_type == "channel" + @pytest.mark.asyncio + async def test_input_resolves_channel_prompt(self, runner): + """Voice input must carry the bound text channel's channel_prompt (#50149).""" + from gateway.config import Platform + mock_adapter = AsyncMock() + mock_adapter._voice_text_channels = {111: 123} + mock_adapter._voice_sources = {} + mock_adapter._client = MagicMock() + mock_adapter._client.get_channel = MagicMock(return_value=AsyncMock()) + mock_adapter.handle_message = AsyncMock() + mock_adapter._resolve_channel_prompt = MagicMock(return_value="Be terse in #dev.") + runner.adapters[Platform.DISCORD] = mock_adapter + await runner._handle_voice_channel_input(111, 42, "Hello from VC") + mock_adapter._resolve_channel_prompt.assert_called_once_with("123") + event = mock_adapter.handle_message.call_args[0][0] + assert event.channel_prompt == "Be terse in #dev." + + @pytest.mark.asyncio + async def test_input_channel_prompt_resolver_failure_is_non_fatal(self, runner): + """A failing channel_prompt resolver must not block voice input.""" + from gateway.config import Platform + mock_adapter = AsyncMock() + mock_adapter._voice_text_channels = {111: 123} + mock_adapter._voice_sources = {} + mock_adapter._client = MagicMock() + mock_adapter._client.get_channel = MagicMock(return_value=AsyncMock()) + mock_adapter.handle_message = AsyncMock() + mock_adapter._resolve_channel_prompt = MagicMock(side_effect=RuntimeError("boom")) + runner.adapters[Platform.DISCORD] = mock_adapter + await runner._handle_voice_channel_input(111, 42, "Hello from VC") + event = mock_adapter.handle_message.call_args[0][0] + assert event.channel_prompt is None + @pytest.mark.asyncio async def test_input_reuses_bound_source_metadata(self, runner): """Voice input should share the linked text channel session metadata."""