fix(gateway): steer busy voice follow-ups after STT

This commit is contained in:
canorionen 2026-07-15 16:31:13 +03:00 committed by Teknium
parent aa40f16d3e
commit 0fd0161dfd
2 changed files with 95 additions and 1 deletions

View file

@ -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

View file

@ -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."""