Skip to content

fix(gateway): thread auto-TTS reply + support voice_out_carries_text adapters - #32655

Open
cristianmgm7 wants to merge 3 commits into
NousResearch:mainfrom
cristianmgm7:fix/tts-reply-and-voice-out-carries-text
Open

fix(gateway): thread auto-TTS reply + support voice_out_carries_text adapters#32655
cristianmgm7 wants to merge 3 commits into
NousResearch:mainfrom
cristianmgm7:fix/tts-reply-and-voice-out-carries-text

Conversation

@cristianmgm7

@cristianmgm7 cristianmgm7 commented May 26, 2026

Copy link
Copy Markdown

Summary

Two related bugs in the auto-TTS dispatch (`gateway/platforms/base.py:_run_message_response`) that surface on every voice-out platform but only became visible while wiring TTS into a new adapter (Carbon Voice). Default behavior for existing adapters is unchanged.

Bug 1 — voice memo arrives as a top-level post, not a reply

The dispatch calls `play_tts(...)` to ship the synthesized audio, but never passes `reply_to`. The parallel text path a few lines below computes `_reply_anchor = _reply_anchor_for_event(event)` and threads correctly; the TTS path doesn't, so the audio bubble arrives detached from context.

Repro on any platform whose `send_voice` honors `reply_to` (Telegram, Carbon Voice, Matrix, …):

  1. User replies inside an existing thread.
  2. `voice.auto_tts: true` + an enabled TTS provider.
  3. The voice memo response posts at the top of the conversation instead of nested under the user's message.

Fix: also pass `reply_to=_reply_anchor_for_event(event)` to `play_tts`. Adapters whose `send_voice` ignores `reply_to` discard it harmlessly via `**kwargs`.

Bug 2 — duplicate text bubble after every TTS reply

`_tts_caption_delivered` only fires for Telegram (via the `telegram_tts_caption` path, which carries the text as the audio's caption field). Every other voice-out platform falls through to the text-send branch and ships a duplicate text bubble right after the voice memo.

For platforms that re-render the spoken text inside the voice bubble themselves — e.g. Carbon Voice runs server-side STT on uploaded audio and shows the transcript inline — that duplicate is pure noise.

Fix: introduce a class-level opt-in on `BasePlatformAdapter`:

```python
voice_out_carries_text: bool = False
```

Subclasses override to True when their voice messages inherently carry the spoken text. The `_tts_caption_delivered` check is widened to:

```python
_tts_caption_delivered = bool(
(
telegram_tts_caption
or getattr(self, "voice_out_carries_text", False)
)
and getattr(tts_result, "success", False)
)
```

Default False — every existing adapter is unaffected. Only adapters that explicitly opt in suppress the duplicate text send. Same opt-in shape as `REQUIRES_EDIT_FINALIZE` a few lines above.

Test plan

New `tests/gateway/test_tts_voice_out.py` (5 cases, all passing locally):

  • `voice_out_carries_text` defaults to False on `BasePlatformAdapter`
  • A subclass can override to True without affecting the base
  • A subclass that doesn't override inherits False
  • The TTS dispatch in `base.py` calls `play_tts` with `reply_to=` (source-grep regression guard)
  • The TTS dispatch reads `voice_out_carries_text` (source-grep regression guard)

The dispatch flow itself is exercised by existing per-platform integration tests; the new unit tests pin the two contracts so they can't quietly regress.

Backwards compatibility

  • `reply_to` is accepted by every `send_voice` override I checked (Telegram, Slack, Discord, Matrix, BlueBubbles, Signal, etc.) and is the same value already passed to `_send_with_retry` for the text path right below. No new behavior for platforms whose `send_voice` ignores it.
  • `voice_out_carries_text` defaults to False on `BasePlatformAdapter` — every existing adapter inherits the False default and continues to send the text bubble after TTS. Only adapters that explicitly opt in change behavior.

Discovered while

…wiring TTS through the Carbon Voice plugin (`PhononX/hermes-plugin-carbonvoice`). CV transcribes uploaded audio server-side, so the voice memo bubble already shows the spoken text — making the duplicate text-send obvious as soon as the auto-TTS path engaged. The threading bug surfaced in the same test: replies in DMs and group threads arrived top-level instead of nested under the user's message.

…adapters

Two related bugs in the auto-TTS dispatch
(``gateway/platforms/base.py:_run_message_response``) that surface on
every voice-out platform but only became visible while wiring TTS
into a new adapter (Carbon Voice):

### Bug 1 — voice memo arrives as a top-level post, not a reply

The dispatch calls ``play_tts(...)`` to ship the synthesized audio,
but never passes ``reply_to``. The parallel text path (a few lines
below) computes ``_reply_anchor = _reply_anchor_for_event(event)``
and threads correctly; the TTS path doesn't, so the audio bubble
arrives as a top-level message even when the user clearly replied
to an existing thread.

Symptom on the recipient side: their inbound is in a thread; the
agent's voice memo answer arrives at the top of the conversation,
detached from context.

Fix: also pass ``reply_to=_reply_anchor_for_event(event)`` to
``play_tts``. Adapters whose ``send_voice`` ignores ``reply_to``
discard it harmlessly via ``**kwargs``.

### Bug 2 — duplicate text bubble after every TTS reply

``_tts_caption_delivered`` only fires for Telegram (via the
``telegram_tts_caption`` path, which actually carries the text as
the audio message's caption field). Every other voice-out platform
falls through to the text-send branch below and ships a duplicate
text bubble right after the voice memo.

For platforms that *re-render the spoken text inside the voice
bubble themselves* — Carbon Voice runs server-side STT on uploaded
audio and shows the transcript inline; future similar platforms —
that duplicate is pure noise.

Fix: introduce a new class attribute on ``BasePlatformAdapter``:

    voice_out_carries_text: bool = False

Subclasses opt in by overriding to True. The
``_tts_caption_delivered`` check is widened to:

    bool(
        (telegram_tts_caption
         or getattr(self, "voice_out_carries_text", False))
        and getattr(tts_result, "success", False)
    )

Default False keeps every existing adapter's behavior unchanged.
Only adapters that explicitly opt in suppress the duplicate text
send — same opt-in shape as ``REQUIRES_EDIT_FINALIZE``.

### Test plan

New ``tests/gateway/test_tts_voice_out.py`` (5 cases, all passing):

  - ``voice_out_carries_text`` defaults to False on
    ``BasePlatformAdapter``.
  - A subclass can override to True without affecting the base.
  - A subclass that doesn't override inherits False.
  - The TTS dispatch in ``base.py`` calls ``play_tts`` with
    ``reply_to=`` (source-grep guard).
  - The TTS dispatch reads ``voice_out_carries_text`` (source-grep
    guard).

The dispatch flow itself is exercised by existing per-platform
integration tests; the new unit tests just pin the contracts so
they can't quietly regress.

### Discovered while

…wiring TTS through the Carbon Voice plugin
(``PhononX/hermes-plugin-carbonvoice``). CV transcribes uploaded
audio server-side, so the voice memo bubble already shows the
spoken text — making the duplicate text-send obvious as soon as
the auto-TTS path engaged. The threading bug surfaced in the same
test: replies in DMs and group threads arrived top-level.
@alt-glitch alt-glitch added P2 Medium — degraded but workaround exists type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery tool/tts Text-to-speech and transcription labels May 26, 2026
@cristianmgm7

Copy link
Copy Markdown
Author

Hi @Zyrixtrex — your commit f8eeb570c ("fix(gateway): avoid duplicate Telegram text after auto-TTS voice replies") was exactly the right pattern; this PR generalizes it via a new voice_out_carries_text class attribute on BasePlatformAdapter so any platform whose voice messages inherently carry the spoken text (Carbon Voice runs server-side STT, future Discord voice-out support, etc.) can opt in the same way. Default stays False so Telegram's existing telegram_tts_caption path is untouched — the change is purely additive for non-Telegram platforms.

Also fixes a parallel asymmetry that lives in the same dispatch block: play_tts(...) was being called without reply_to, so the TTS audio arrived as a top-level post even when the parallel text path (a few lines below) threaded its reply under the user's message. Same call site, three-line addition.

Discovered while wiring TTS through a Carbon Voice plugin (PhononX/hermes-plugin-carbonvoice) where both bugs surfaced together — CV transcribes uploaded audio server-side, so the duplicate text bubble was immediately visible, and the non-threaded audio was obvious in DM/group threads. Local patch validates fully end-to-end; the PR's 5 unit tests pin both contracts as source-grep guards plus class-attribute defaults.

Would love your eyes on it whenever you have a moment 🙏

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for identifying the auto-TTS reply-threading asymmetry. The first premise is still present on current main: gateway/platforms/base.py:5010-5014 calls play_tts() without reply_to, while the text path supplies the anchor at gateway/platforms/base.py:5028-5033.

Problems

  • The new suppression branch can lose content. TTS receives speech_text from gateway/platforms/base.py:4988, and prepare_tts_text() strips formatting and truncates to 4000 characters at gateway/platforms/base.py:3381-3386. A successful opted-in voice send would suppress the full text even when the rendered transcript cannot contain all of it.
  • PR commit 093e6c14544b uses source-string assertions. tests/gateway/test_base_topic_sessions.py:277-347 already drives this dispatch flow and is the appropriate place to test the actual arguments and delivery outcome.

Suggested changes

  • Suppress the text only when the adapter can guarantee that the voice transcript covers the complete response; retain it for prepared/truncated responses.
  • Add behavioral tests for reply_to, successful opt-in suppression, failed-TTS fallback, and a long opted-in response.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 13, 2026
… full response

Review feedback on NousResearch#32655: prepare_tts_text() strips markdown and
truncates to 4000 chars, so an opted-in voice_out_carries_text adapter
could suppress the full text bubble even when the voice transcript
couldn't carry all of it.

- Gate the suppression on _tts_speech_is_complete (speech_text equals
  the untouched text_content); prepared/truncated responses keep the
  follow-up text send.
- Replace the source-grep assertions with behavioral tests in
  tests/gateway/test_base_topic_sessions.py driving
  _process_message_background end-to-end: reply_to anchor on play_tts,
  successful opt-in suppression, failed-TTS fallback, long (truncated)
  reply, formatted (stripped) reply, and non-opted-in default.
- test_tts_voice_out.py now only pins the class-attribute contract.
…ce-out-carries-text

# Conflicts:
#	gateway/platforms/base.py
@cristianmgm7

Copy link
Copy Markdown
Author

Thanks for the review — both points addressed.

Content loss on suppression → fixed in 26b6707. The dispatch now tracks _tts_speech_is_complete = speech_text == text_content and the voice_out_carries_text suppression only fires when that holds, i.e. when prepare_tts_text() neither stripped formatting nor truncated anything. Prepared/truncated responses keep the follow-up text send even on opted-in adapters, so the transcript can never silently replace a longer or formatted response. The Telegram caption path is unchanged.

Source-string assertions → replaced with behavioral tests in tests/gateway/test_base_topic_sessions.py (TestVoiceOutCarriesTextDelivery), driving _process_message_background end-to-end through a non-Telegram opted-in dummy adapter. Covers the four cases you listed plus two more:

  • play_tts receives the reply_to anchor
  • successful opt-in suppression (short plain reply → no follow-up text)
  • failed-TTS fallback (text still delivered)
  • long opted-in response (truncated speech → full text retained)
  • formatted response (stripped markdown → full text retained)
  • non-opted-in adapter default unchanged (text still sent after voice)

tests/gateway/test_tts_voice_out.py now only pins the class-attribute contract (default False, subclass override); the source-grep guards are gone.

Also merged current main (ad18a4a) to resolve the overlap with the _final_thread_metadata refactor, so the branch is conflict-free again. All 19 tests in the two touched files pass, ruff clean.

cristianmgm7 added a commit to cristianmgm7/hermes-agent that referenced this pull request Jul 16, 2026
…earch#32655

Drops this PR's own voice_out_carries_text implementation in base.py:
the same contract already exists in PR NousResearch#32655, where it is further
along in review and hardened per feedback (completeness guard so a
truncated TTS rendition never suppresses the full text, plus reply_to
threading for the voice memo and behavioral tests).

The plugin keeps declaring voice_out_carries_text = True; until NousResearch#32655
lands the attribute is simply ignored (voice-out delivers audio plus a
duplicate text bubble — degraded UX, never lost content). This PR is
now strictly zero core changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cristianmgm7

Copy link
Copy Markdown
Author

Cross-reference: #43226 (Carbon Voice platform plugin) declares voice_out_carries_text = True on its adapter and relies on this PR's contract for the audio/text suppression — its reviewer asked for "a tested general delivery contract for Carbon Voice audio/text suppression", which is exactly this PR. The two are merge-order independent.

@andrexibiza

Copy link
Copy Markdown
Contributor

Verification note (Vox Lockin lane 02 — auto-TTS / voice-mode reply class)

Verified against origin/main @ 70db671fac (2026-08-04). Head: ad18a4aad0.

Finding: not mergeable as-is; out of the lane-02 corpus class; no duplicate/salvage from this lane.

Recommendation: rebase onto main, drop the superseded threading hunks, keep the voice_out_carries_text opt-in + completeness guard. Happy to re-verify after rebase.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages tool/tts Text-to-speech and transcription type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants