fix(gateway): pass channel_prompt into voice-channel STT events; guard empty transcripts

Two hand-written fixes in the voice input path:

- _handle_voice_channel_input now resolves the bound text channel's
  channel_prompt via the adapter's _resolve_channel_prompt so voice input
  gets the same per-channel context as typed messages (fixes #50149).
- _enrich_message_with_transcription now guards success=True results whose
  transcript is empty/whitespace-only (silence, cut-off, inaudible audio):
  instead of emitting empty quotes the agent gets a clear sentinel note.
  Reimplemented against the current plain-quoted note wording; original
  concept and tests by @deacon-botdoctor in PR #41603 (fixes #41603).
This commit is contained in:
Teknium 2026-07-28 09:28:18 -07:00
parent a4c9994837
commit f76b2b47aa
3 changed files with 81 additions and 0 deletions

View file

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

View file

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

View file

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