From 0fd0161dfdbea0d9339cfccebeb9500e88aa1ba1 Mon Sep 17 00:00:00 2001 From: canorionen <163772390+canorionen@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:31:13 +0300 Subject: [PATCH] fix(gateway): steer busy voice follow-ups after STT --- gateway/run.py | 58 +++++++++++++++++++++++++- tests/gateway/test_busy_session_ack.py | 38 +++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/gateway/run.py b/gateway/run.py index 9dce5a27cd96..79795a49fb4e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6160,6 +6160,62 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew self._enqueue_fifo(session_key, event, adapter) + async def _prepare_busy_steer_text(self, event: MessageEvent) -> str: + """Return steerable text for a busy follow-up, transcribing voice first. + + Fresh and queued voice messages reach the normal inbound STT pipeline, + but successful steer messages intentionally bypass that queue. Without + preprocessing here, a media-only voice follow-up has an empty text + payload and steer mode silently degrades to queue mode. + + Audio file attachments remain files; only voice-message media follows + the automatic STT contract used by ``_prepare_inbound_message_text``. + If transcription fails, preserve any caption and let the existing + steer fallback handle an otherwise empty event without losing it. + """ + text = (event.text or "").strip() + media_urls = getattr(event, "media_urls", None) or [] + media_types = getattr(event, "media_types", None) or [] + voice_paths: List[str] = [] + + for index, path in enumerate(media_urls): + media_type = media_types[index] if index < len(media_types) else "" + is_voice = event.message_type == MessageType.VOICE or ( + media_type.startswith("audio/") + and event.message_type not in {MessageType.AUDIO, MessageType.DOCUMENT} + ) + if is_voice: + voice_paths.append(path) + + if not voice_paths: + return text + + enriched_text, successful_transcripts = await self._enrich_message_with_transcription( + text, + voice_paths, + ) + if not successful_transcripts: + return text + + if self._should_echo_stt_transcripts(): + adapter = self._adapter_for_source(event.source) + if adapter: + echo_meta = self._thread_metadata_for_source( + event.source, + self._reply_anchor_for_event(event), + ) + for transcript in successful_transcripts: + try: + await adapter.send( + event.source.chat_id, + f'🎙️ "{transcript}"', + metadata=echo_meta, + ) + except Exception as exc: + logger.debug("Busy-steer transcript echo failed (non-fatal): %s", exc) + + return (enriched_text or text).strip() + async def _handle_active_session_busy_message(self, event: MessageEvent, session_key: str) -> bool: # --- Authorization gate (#17775) --- # The cold path (_handle_message) checks _is_user_authorized before @@ -6347,7 +6403,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew steered = False redirected = False if effective_mode == "steer": - steer_text = (event.text or "").strip() + steer_text = await self._prepare_busy_steer_text(event) can_steer = ( steer_text and event.message_type == MessageType.TEXT diff --git a/tests/gateway/test_busy_session_ack.py b/tests/gateway/test_busy_session_ack.py index 8b8598757992..20dfcc0eb3f7 100644 --- a/tests/gateway/test_busy_session_ack.py +++ b/tests/gateway/test_busy_session_ack.py @@ -349,6 +349,44 @@ class TestBusySessionAck: assert "Steered" in content or "steer" in content.lower() assert "Interrupting" not in content + @pytest.mark.asyncio + async def test_steer_mode_transcribes_voice_before_injection(self, monkeypatch): + """A busy voice follow-up is transcribed and steered, never queued.""" + import gateway.run as _gr + + monkeypatch.delenv("HERMES_GATEWAY_BUSY_STEER_ACK_ENABLED", raising=False) + monkeypatch.setattr(_gr, "_load_gateway_config", lambda: {}) + runner, _sentinel = _make_runner() + runner._busy_input_mode = "steer" + runner._should_echo_stt_transcripts = MagicMock(return_value=False) + runner._enrich_message_with_transcription = AsyncMock( + return_value=('"yönü teknik mimariye çevir"', ["yönü teknik mimariye çevir"]) + ) + adapter = _make_adapter() + + event = _make_event(text="") + event.message_type = MessageType.VOICE + event.media_urls = ["/tmp/follow-up.ogg"] + event.media_types = ["audio/ogg"] + sk = build_session_key(event.source) + runner.adapters[event.source.platform] = adapter + + agent = MagicMock() + agent.steer = MagicMock(return_value=True) + runner._running_agents[sk] = agent + + await runner._handle_active_session_busy_message(event, sk) + + runner._enrich_message_with_transcription.assert_awaited_once_with( + "", ["/tmp/follow-up.ogg"] + ) + agent.steer.assert_called_once_with('"yönü teknik mimariye çevir"') + agent.interrupt.assert_not_called() + assert sk not in adapter._pending_messages + content = adapter._send_with_retry.call_args.kwargs["content"] + assert "Steered" in content + assert "Queued" not in content + @pytest.mark.asyncio async def test_steer_mode_can_suppress_visible_ack_without_disabling_steer(self, monkeypatch): """busy_steer_ack_enabled=false keeps steering but drops the echo bubble."""