Skip to content

fix(gateway): normalize legacy bool reply_to_mode so 'false' truly means 'off' (#29623)#29639

Open
xxxigm wants to merge 2 commits into
NousResearch:mainfrom
xxxigm:fix/29623-discord-reply-to-mode-false-normalize
Open

fix(gateway): normalize legacy bool reply_to_mode so 'false' truly means 'off' (#29623)#29639
xxxigm wants to merge 2 commits into
NousResearch:mainfrom
xxxigm:fix/29623-discord-reply-to-mode-false-normalize

Conversation

@xxxigm

@xxxigm xxxigm commented May 21, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes #29623 — Discord (and Telegram) configs that carry the legacy boolean reply_to_mode: false were silently regressing to "first" on the current runtime, so the bot kept replying to the triggering message instead of sending it standalone.

Root cause.
PlatformConfig.from_dict did:

reply_to_mode=data.get("reply_to_mode", "first"),

— no normalisation, so False was stored verbatim. The Discord and Telegram adapter constructors then did:

self._reply_to_mode = getattr(config, 'reply_to_mode', 'first') or 'first'

False or 'first' evaluates to 'first', which is exactly the opposite of what the user asked for. The YAML→env bridge in load_gateway_config already coerced False → "off" for top-level discord: / telegram: sections, but the from_dict path (used by display.platforms.discord: and platforms.discord: blocks) bypassed that bridge entirely.

Fix.

  • New gateway.config.normalize_reply_to_mode(value, *, default=…) is the single source of truth: False/"false"/"no"/"0"/0"off", True/"true"/"yes"/"1"/1 → default, None/missing/garbage → default, case- and whitespace-insensitive off/first/all round-trip canonically.
  • PlatformConfig.__post_init__ runs the normaliser, so every construction path (from_dict, env-var bridge, direct PlatformConfig(reply_to_mode=…) in tests and embedders) lands on a canonical string.
  • DiscordAdapter.__init__ and TelegramAdapter.__init__ apply the same normaliser defensively, so the rare stub-config path that bypasses the dataclass (downstream embedders that hand-roll a duck-typed config object) still produces a canonical mode.

The reporter's exact repro now prints False -> off instead of False -> False.

Related Issue

Closes #29623Discord reply_to_mode: false regresses to first on current runtime.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change) — centralises the previously scattered or 'first' / _rtm_str = "off" if … is False else … logic into one helper
  • 🎯 New skill (bundled or hub)

Changes Made

  • gateway/config.py

    • New module-level constants: REPLY_TO_MODES = ("off", "first", "all") and DEFAULT_REPLY_TO_MODE = "first" — both used as the public contract surface for tests and adapter code.
    • New normalize_reply_to_mode(value, *, default=DEFAULT_REPLY_TO_MODE) -> str helper with the full rule table (see fix description above).
    • PlatformConfig.reply_to_mode default switched to DEFAULT_REPLY_TO_MODE (same string, semantically labelled).
    • New PlatformConfig.__post_init__ that runs every constructed config through the normaliser — the single point of truth.
    • PlatformConfig.from_dict now calls the normaliser explicitly at the boundary (redundant with __post_init__ but documents the contract).
  • gateway/platforms/discord.pyDiscordAdapter.__init__ swaps the brittle getattr(config, 'reply_to_mode', 'first') or 'first' for the new normaliser, with a comment that points at gateway.config.normalize_reply_to_mode and Bug: Discord reply_to_mode: false regresses to first on current runtime #29623.

  • gateway/platforms/telegram.py — same defensive normalisation pattern in TelegramAdapter.__init__.

  • tests/gateway/test_discord_reply_mode.py and tests/gateway/test_telegram_reply_mode.py — flip the two test_invalid_mode_stored_as_is assertions to test_invalid_mode_normalised_to_default so they pin the improved contract instead of the bug surface they used to lock in.

  • tests/gateway/test_reply_to_mode_normalization.py (new, +329 lines, 64 cases across six classes):

    • TestNormalizeReplyToModeHelper — pure-helper contract (parametrised over ~30 inputs, plus test_custom_default_is_honoured, test_output_is_always_in_REPLY_TO_MODES, test_default_constant_is_in_modes).
    • TestReporterRepro29623 — the exact transcript pasted in the issue body, asserted as four separate tests.
    • TestPlatformConfigPostInit — every direct PlatformConfig(reply_to_mode=…) is normalised, to_dict no longer re-emits the legacy bool, and a guardrail catches a future __init__ that silently drops __post_init__.
    • TestEnvVarBridgeNormalises — the env-var path through load_gateway_config also lands on canonical values.
    • TestAdapterDefensiveNormalisation — duck-typed stub configs (downstream embedders) still get a canonical mode out of DiscordAdapter / via the normalize_reply_to_mode(getattr(stub, 'reply_to_mode', None)) helper used in TelegramAdapter.
    • TestLegacyBoolRoundTrip — every legacy off-ish input survives from_dict → to_dict → from_dict as "off", so a config written by an older Hermes can't reanimate the bug on the next save.

How to Test

  1. Check out the branch and activate the venv:
    git fetch origin fix/29623-discord-reply-to-mode-false-normalize
    git checkout fix/29623-discord-reply-to-mode-false-normalize
    source .venv/bin/activate   # or: python3 -m venv .venv && source .venv/bin/activate && pip install -e ".[all,dev]"
    
  2. Run the new regression file on its own:
    scripts/run_tests.sh tests/gateway/test_reply_to_mode_normalization.py -v
    
    Expected: 64 passed.
  3. Run the combined reply-mode suite (covers the updated existing tests too):
    scripts/run_tests.sh tests/gateway/test_reply_to_mode_normalization.py tests/gateway/test_discord_reply_mode.py tests/gateway/test_telegram_reply_mode.py
    
    Expected: 137 passed.
  4. Run the broader gateway-config suite to confirm no cross-file regressions:
    scripts/run_tests.sh tests/gateway/test_config.py tests/gateway/test_config_cwd_bridge.py tests/gateway/test_display_config.py tests/gateway/test_runtime_env_reload_config_authority.py tests/gateway/test_config_env_bridge_authority.py
    
    Expected: 111 passed.
  5. Reporter's exact one-liner:
    python -c "from gateway.config import PlatformConfig; print(PlatformConfig.from_dict({'enabled': True, 'reply_to_mode': False}).reply_to_mode)"
    
    Expected: off (before this PR: False).

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(gateway): … and test(gateway): …)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix (no unrelated commits)
  • I've run scripts/run_tests.sh tests/gateway/test_reply_to_mode_normalization.py tests/gateway/test_discord_reply_mode.py tests/gateway/test_telegram_reply_mode.py and all 137 tests pass
  • I've added tests for my changes (64 new regression cases across six classes + updated two existing assertions to reflect the improved contract)
  • I've tested on my platform: macOS 15.2 (Darwin 24.6.0), Python 3.12

Documentation & Housekeeping

  • I've updated relevant documentation — the inline docstring on normalize_reply_to_mode documents the full rule table; no user-facing docs change is needed (the YAML key + accepted string values are unchanged — legacy bool values just now resolve correctly)
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A (no new config keys)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — pure config-shape change, no OS-specific code paths
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

Screenshots / Logs

$ scripts/run_tests.sh tests/gateway/test_reply_to_mode_normalization.py -v
4 workers [64 items]
............................................................... [100%]
============================== 64 passed in 1.23s ==============================

$ scripts/run_tests.sh tests/gateway/test_reply_to_mode_normalization.py tests/gateway/test_discord_reply_mode.py tests/gateway/test_telegram_reply_mode.py
4 workers [137 items]
============================== 137 passed in 1.40s ==============================

$ python -c "from gateway.config import PlatformConfig; print(PlatformConfig.from_dict({'enabled': True, 'reply_to_mode': False}).reply_to_mode)"
off

xxxigm added 2 commits May 21, 2026 11:09
…ans 'off' (NousResearch#29623)

Older Discord/Telegram configs (or migrated configs that came in via the
YAML 1.1 ``off``-as-bool trap) can still contain ``reply_to_mode: false``
under ``display.platforms.discord:`` / ``platforms.discord:`` blocks
that the YAML→env bridge doesn't pre-process. ``PlatformConfig.from_dict``
then stored the raw ``False``, and the adapter's
``getattr(config, 'reply_to_mode', 'first') or 'first'`` mapped that
falsy value back to ``"first"`` — exactly the opposite of what the user
asked for.

Add ``gateway.config.normalize_reply_to_mode(value)`` as the single
source of truth for the contract (``False`` → ``"off"``, ``True`` →
default, ``None``/garbage → default, case+whitespace-insensitive
``off``/``first``/``all``), wire it into ``PlatformConfig.__post_init__``
so every construction path (``from_dict``, env-var bridge, direct
``PlatformConfig(reply_to_mode=…)``) lands on a canonical string, and
also apply it defensively in the Discord and Telegram adapter
constructors for the rare stub-config path that bypasses the dataclass.

Two existing ``test_invalid_mode_stored_as_is`` assertions pinned the
pre-fix "store garbage as-is" behaviour; both are flipped to the
improved contract here.
…ousResearch#29623)

Adds tests/gateway/test_reply_to_mode_normalization.py — 64 cases
across six classes:

- ``TestNormalizeReplyToModeHelper`` (parametrised + 3 standalone):
  pure contract of the new helper — every bool / str / numeric / None
  / garbage input lands on one of ``REPLY_TO_MODES``, the
  ``default=`` override is honoured for missing/garbage but NOT for
  the explicit boolean ``False`` intent, and ``DEFAULT_REPLY_TO_MODE``
  itself is always inside the public tuple.

- ``TestReporterRepro29623`` (4 cases): the exact transcript from the
  issue body — ``PlatformConfig.from_dict({'reply_to_mode': False})``
  now resolves to ``"off"`` (was ``False``), ``"off"`` round-trips,
  missing keys still default to ``"first"``, plus a single-shot test
  that asserts all three rows of the reporter's table at once.

- ``TestPlatformConfigPostInit`` (parametrised + 3): every direct
  ``PlatformConfig(reply_to_mode=…)`` construction style is normalised,
  ``to_dict`` no longer re-emits the legacy bool, and a guardrail
  proves ``__post_init__`` actually runs (a future ``__init__`` that
  silently swallows it would trip here).

- ``TestEnvVarBridgeNormalises`` (2): ``DISCORD_REPLY_TO_MODE`` lands
  via ``load_gateway_config`` into a ``PlatformConfig`` that still
  goes through the normaliser.

- ``TestAdapterDefensiveNormalisation`` (4): even stub configs that
  bypass the dataclass entirely (``_StubConfig`` duck-typed) are
  handled by the adapter-side defensive call, so the bug surface is
  closed regardless of how the config object was built.

- ``TestLegacyBoolRoundTrip`` (parametrised): every legacy ``off``-ish
  input (``False`` / ``"off"`` / ``"false"`` / ``"no"`` / ``"0"`` /
  ``0``) survives a from_dict→to_dict→from_dict cycle as ``"off"``,
  so configs written by older Hermes versions can't reanimate the bug
  on the next save.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery platform/discord Discord bot adapter platform/telegram Telegram bot adapter labels May 21, 2026
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 platform/discord Discord bot adapter platform/telegram Telegram bot adapter type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Discord reply_to_mode: false regresses to first on current runtime

2 participants