Files
hermes-agent/tests/gateway/test_voice_transcript_dedup_scope.py
unsupportedpastels a036b13793 fix(discord): a programmatic voice join binds the requested text channel on every path
join_voice_channel(channel, text_channel_id=..., source=...) is the programmatic join API, and it
kept the requested binding only on a cold join: the already-connected paths (same voice channel, or a
move) returned before writing it, so a successful join to another text channel kept transcribing
into the old one. All three paths now apply the binding through one helper. Moving to another text
channel drops speech still buffered for the old one, as /voice join does, and the old channel's bound
source unless a new one is given: its chat would keep routing turns into the old conversation.

With no bound source, a voice turn built a synthetic one with chat_type "channel". A typed message in
a guild text channel is "group" (or "thread" under its parent), so the voice turn keyed another
session. It now goes through build_source as a typed message does, with the guild, thread and parent.

/voice join, the only in-repo caller, already writes its binding itself after joining, so this is
latent until something calls join_voice_channel with a text channel (the open auto-join PRs would).

Reported by @andrexibiza as F7 in #132474.
2026-10-06 00:08:57 -04:00

53 lines
2.2 KiB
Python

"""Voice-transcript dedup belongs to the bot that heard the utterance and the conversation it is bound to.
The runner keeps one dedup store for every Discord bot it serves. Keyed by guild and speaker alone, a
second bot (another profile) in the same guild dropped its own copy of an utterance because the first
bot had just recorded it, and a rebind to another text channel inherited the old binding's history.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
import gateway.run as gateway_run
from gateway.config import Platform
from gateway.session import SessionSource
_GUILD, _USER = 1, 42
class _Bot:
"""What the voice callback reads from a Discord adapter (weakref-able)."""
def __init__(self, profile: str | None) -> None:
self._owner_profile = profile
self._voice_text_channels, self._voice_sources = {_GUILD: 700}, {}
self._client = SimpleNamespace(get_channel=lambda _id: None, get_guild=lambda _id: None)
self.handle_message = AsyncMock()
def build_source(self, **fields) -> SessionSource:
return SessionSource(platform=Platform.DISCORD, profile=self._owner_profile, **fields)
@pytest.mark.asyncio
async def test_dedup_is_scoped_to_the_receiving_bot_and_its_binding() -> None:
bot_a, bot_b = _Bot(None), _Bot("bot-b")
runner = object.__new__(gateway_run.GatewayRunner)
runner.adapters = {Platform.DISCORD: bot_a}
runner._voice_mode, runner._session_db, runner.session_store = {}, None, MagicMock()
runner._canonicalize = lambda source, **_kw: object()
runner._is_user_authorized = lambda source: True
async def say(bot: _Bot, text: str = "what broke on the ingest box") -> None:
await runner._handle_voice_channel_input(_GUILD, _USER, text, adapter=bot)
await say(bot_a)
await say(bot_b)
await say(bot_a) # the same capture emitted twice: still suppressed
assert (bot_a.handle_message.await_count, bot_b.handle_message.await_count) == (1, 1)
bot_a._voice_text_channels[_GUILD] = 800 # /voice join from another text channel
await say(bot_a)
assert bot_a.handle_message.await_count == 2