Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -5359,6 +5359,62 @@ def _queue_or_replace_pending_event(self, session_key: str, event: MessageEvent)

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
Expand Down Expand Up @@ -5545,7 +5601,7 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session
effective_mode = "queue"
steered = 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 running_agent is not None
Expand Down
38 changes: 38 additions & 0 deletions tests/gateway/test_busy_session_ack.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,44 @@ async def test_steer_mode_calls_agent_steer_no_interrupt_no_queue(self, monkeypa
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."""
Expand Down