{"repo":"NousResearch/hermes-agent","public_id":"3505","display_label":"Message handling during active agent/tool execution","summary":"These items address messages and data being lost or silently dropped while an agent is actively running or executing tools. The fixes involve draining steering input between tool calls and acknowledging user messages received during a busy session.","subsystem":"agent","counts":{"members":17,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":6,"merged_prs":3,"attempt_prs":8},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12334},"resolution":{"state":"partially_resolved","by":"pr-12334,pr-26822,pr-33817"},"freshness":{"state":"verified","last_verified_at":"2026-06-08T09:02:03+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.10735294117647057},"fix_edges":[{"issue_number":4469,"pr_number":26822,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":5057,"pr_number":12334,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":28503,"pr_number":33817,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":41572,"pr_number":12334,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":3505,"title":"gateway: queue inbound messages per session instead of overwriting latest pending message","url":null,"state":"closed"},{"kind":"issue","number":4469,"title":"Bug: Multiple rapid messages only last one processed","url":null,"state":"closed"},{"kind":"issue","number":4947,"title":"Message loss: single-slot pending message overwrite during interrupt handling","url":null,"state":"closed"},{"kind":"issue","number":5057,"title":"Message handling behavior when multiple messages arrive during active agent execution","url":null,"state":"closed"},{"kind":"issue","number":22818,"title":"CLI messages silently dropped during tool execution","url":null,"state":"closed"},{"kind":"issue","number":28503,"title":"Bug: busy_input_mode: queue silently drops messages — single-slot overwrite instead of FIFO","url":null,"state":"closed"},{"kind":"pr","number":3520,"title":"fix(gateway): implement message queuing to prevent overwriting during interrupts  ","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3505","number":3505}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":4491,"title":"fix(gateway): synchronize gateway and adapter pending message queues …","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4469","number":4469}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":4989,"title":"fix(gateway): queue pending interrupt messages FIFO (#4947)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4947","number":4947}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":6252,"title":"fix(gateway): keep /model and /queue non-interrupting and execute queued /model post-turn","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"632a807a3e528169bd39baf8fc3aa1d641580e96","number":0},{"kind":"pr","value":"#5057","number":5057},{"kind":"pr","value":"#12334","number":12334},{"kind":"pr","value":"#5057","number":5057},{"kind":"pr","value":"#6252","number":6252}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":12334,"title":"fix(gateway): slash commands never interrupt a running agent","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/discord.py","gateway/run.py","hermes_cli/commands.py","tests/gateway/test_command_bypass_active_session.py"],"fix_hunks":[{"file":"gateway/platforms/discord.py","hunks":["@@ -1933,6 +1933,24 @@ async def _run_simple_slash(\n         the \"thinking...\" indicator is replaced with that text; otherwise it\n         is deleted so the channel isn't cluttered.\n         \"\"\"\n+        # Log the invoker so ghost-command reports can be triaged.  Discord\n+        # native slash invocations are always user-initiated (no bot can fire\n+        # them), but mobile autocomplete / keyboard shortcuts / other users\n+        # in the same channel are easy to miss in post-mortems.\n+        try:\n+            _user = interaction.user\n+            _chan_id = getattr(interaction.channel, \"id\", None) or getattr(interaction, \"channel_id\", None)\n+            logger.info(\n+                \"[Discord] slash '%s' invoked by user=%s id=%s channel=%s guild=%s\",\n+                command_text,\n+                getattr(_user, \"name\", \"?\"),\n+                getattr(_user, \"id\", \"?\"),\n+                _chan_id,\n+                getattr(interaction, \"guild_id\", None),\n+            )\n+        except Exception:\n+            pass  # logging must never block command dispatch\n+\n         await interaction.response.defer(ephemeral=True)\n         event = self._build_slash_event(interaction, command_text)\n         await self.handle_message(event)"]},{"file":"gateway/run.py","hunks":["@@ -2987,8 +2987,8 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n \n             # Resolve the command once for all early-intercept checks below.\n             from hermes_cli.commands import (\n+                ACTIVE_SESSION_BYPASS_COMMANDS as _DEDICATED_HANDLERS,\n                 resolve_command as _resolve_cmd_inner,\n-                should_bypass_active_session as _should_bypass_active_inner,\n             )\n             _evt_cmd = event.get_command()\n             _cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None","@@ -3123,11 +3123,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n             if _cmd_def_inner and _cmd_def_inner.name == \"background\":\n                 return await self._handle_background_command(event)\n \n-            # Gateway-handled info/control commands must never fall through to\n-            # the interrupt path. If they are queued as pending text, the\n-            # slash-command safety net discards them before the user sees any\n-            # response.\n-            if _cmd_def_inner and _should_bypass_active_inner(_cmd_def_inner.name):\n+            # Gateway-handled info/control commands with dedicated\n+            # running-agent handlers.\n+            if _cmd_def_inner and _cmd_def_inner.name in _DEDICATED_HANDLERS:\n                 if _cmd_def_inner.name == \"help\":\n                     return await self._handle_help_command(event)\n                 if _cmd_def_inner.name == \"commands\":","@@ -3137,6 +3135,21 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                 if _cmd_def_inner.name == \"update\":\n                     return await self._handle_update_command(event)\n \n+            # Catch-all: any other recognized slash command reached the\n+            # running-agent guard. Reject gracefully rather than falling\n+            # through to interrupt + discard. Without this, commands\n+            # like /model, /reasoning, /voice, /insights, /title,\n+            # /resume, /retry, /undo, /compress, /usage, /provider,\n+            # /reload-mcp, /sethome, /reset (all registered as Discord\n+            # slash commands) would interrupt the agent AND get\n+            # silently discarded by the slash-command safety net,\n+            # producing a zero-char response. See #5057, #6252, #10370.\n+            if _cmd_def_inner:\n+                return (\n+                    f\"⏳ Agent is running — `/{_cmd_def_inner.name}` can't run \"\n+                    f\"mid-turn. Wait for the current response or `/stop` first.\"\n+                )\n+\n             if event.message_type == MessageType.PHOTO:\n                 logger.debug(\"PRIORITY photo follow-up for session %s — queueing without interrupt\", _quick_key[:20])\n                 adapter = self.adapters.get(source.platform)"]},{"file":"hermes_cli/commands.py","hunks":["@@ -260,10 +260,10 @@ def _build_description(cmd: CommandDef) -> str:\n )\n \n \n-# Commands that must never be queued behind an active gateway session.\n-# These are explicit control/info commands handled by the gateway itself;\n-# if they get queued as pending text, the safety net in gateway.run will\n-# discard them before they ever reach the user.\n+# Commands with explicit Level-2 running-agent handlers in gateway/run.py.\n+# Listed here for introspection / tests; semantically a subset of\n+# \"all resolvable commands\" — which is the real bypass set (see\n+# should_bypass_active_session below).\n ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(\n     {\n         \"agents\",","@@ -285,9 +285,26 @@ def _build_description(cmd: CommandDef) -> str:\n \n \n def should_bypass_active_session(command_name: str | None) -> bool:\n-    \"\"\"Return True when a slash command must bypass active-session queuing.\"\"\"\n-    cmd = resolve_command(command_name) if command_name else None\n-    return bool(cmd and cmd.name in ACTIVE_SESSION_BYPASS_COMMANDS)\n+    \"\"\"Return True for any resolvable slash command.\n+\n+    Rationale: every gateway-registered slash command either has a\n+    specific Level-2 handler in gateway/run.py (/stop, /new, /model,\n+    /approve, etc.) or reaches the running-agent catch-all that returns\n+    a \"busy — wait or /stop first\" response. In both paths the command\n+    is dispatched, not queued.\n+\n+    Queueing is always wrong for a recognized slash command because the\n+    safety net in gateway.run discards any command text that reaches\n+    the pending queue — which meant a mid-run /model (or /reasoning,\n+    /voice, /insights, /title, /resume, /retry, /undo, /compress,\n+    /usage, /provider, /reload-mcp, /sethome, /reset) would silently\n+    interrupt the agent AND get discarded, producing a zero-char\n+    response. See issue #5057 / PRs #6252, #10370, #4665.\n+\n+    ACTIVE_SESSION_BYPASS_COMMANDS remains the subset of commands with\n+    explicit Level-2 handlers; the rest fall through to the catch-all.\n+    \"\"\"\n+    return resolve_command(command_name) is not None if command_name else False\n \n \n def _resolve_config_gates() -> set[str]:"]},{"file":"tests/gateway/test_command_bypass_active_session.py","hunks":["@@ -268,6 +268,82 @@ async def test_queue_bypasses_guard(self):\n         )\n \n \n+# ---------------------------------------------------------------------------\n+# Tests: non-bypass-set commands (no dedicated Level-2 handler) also bypass\n+# instead of interrupting + being discarded.  Regression for the Discord\n+# ghost-slash-command bug where /model, /reasoning, /voice, /insights, /title,\n+# /resume, /retry, /undo, /compress, /usage, /provider, /reload-mcp,\n+# /sethome, /reset silently interrupted the running agent.\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestAllResolvableCommandsBypassGuard:\n+    \"\"\"Every recognized slash command must bypass the Level-1 active-session\n+    guard. Without this, commands the user fires mid-run interrupt the agent\n+    AND get silently discarded by the slash-command safety net (zero-char\n+    response).\"\"\"\n+\n+    @pytest.mark.parametrize(\n+        \"command_text,canonical\",\n+        [\n+            (\"/model claude-sonnet-4\", \"model\"),\n+            (\"/model\", \"model\"),\n+            (\"/reasoning high\", \"reasoning\"),\n+            (\"/personality default\", \"personality\"),\n+            (\"/voice on\", \"voice\"),\n+            (\"/insights 7\", \"insights\"),\n+            (\"/title my session\", \"title\"),\n+            (\"/resume yesterday\", \"resume\"),\n+            (\"/retry\", \"retry\"),\n+            (\"/undo\", \"undo\"),\n+            (\"/compress\", \"compress\"),\n+            (\"/usage\", \"usage\"),\n+            (\"/provider\", \"provider\"),\n+            (\"/reload-mcp\", \"reload-mcp\"),\n+            (\"/sethome\", \"sethome\"),\n+        ],\n+    )\n+    @pytest.mark.asyncio\n+    async def test_command_bypasses_guard(self, command_text, canonical):\n+        \"\"\"Any resolvable slash command bypasses instead of being queued.\"\"\"\n+        adapter = _make_adapter()\n+        sk = _session_key()\n+        adapter._active_sessions[sk] = asyncio.Event()\n+\n+        await adapter.handle_message(_make_event(command_text))\n+\n+        assert sk not in adapter._pending_messages, (\n+            f\"{command_text} was queued as pending — it should bypass the guard\"\n+        )\n+        assert len(adapter.sent_responses) > 0, (\n+            f\"{command_text} produced no response — it should be dispatched, \"\n+            \"not silently discarded\"\n+        )\n+\n+    def test_should_bypass_returns_true_for_every_registered_command(self):\n+        \"\"\"Spot-check: the commands previously-broken on Discord all bypass.\"\"\"\n+        from hermes_cli.commands import should_bypass_active_session\n+\n+        for cmd in (\n+            \"model\", \"reasoning\", \"personality\", \"voice\", \"insights\", \"title\",\n+            \"resume\", \"retry\", \"undo\", \"compress\", \"usage\", \"provider\",\n+            \"reload-mcp\", \"sethome\", \"reset\",\n+        ):\n+            assert should_bypass_active_session(cmd) is True, (\n+                f\"/{cmd} must bypass the active-session guard\"\n+            )\n+\n+    def test_should_bypass_returns_false_for_unknown(self):\n+        \"\"\"Unknown words don't bypass — they get queued as user text.\"\"\"\n+        from hermes_cli.commands import should_bypass_active_session\n+\n+        assert should_bypass_active_session(\"foobar\") is False\n+        assert should_bypass_active_session(None) is False\n+        assert should_bypass_active_session(\"\") is False\n+        # A file path split on whitespace: '/path/to/file.py' -> 'path/to/file.py'\n+        assert should_bypass_active_session(\"path/to/file.py\") is False\n+\n+\n # ---------------------------------------------------------------------------\n # Tests: non-bypass messages still get queued\n # ---------------------------------------------------------------------------\n"]}]},{"kind":"pr","number":25411,"title":"fix: queue rapid text messages, log paste collapses (#4469, #17666)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4491","number":4491},{"kind":"pr","value":"#17666","number":17666},{"kind":"pr","value":"#4491","number":4491},{"kind":"pr","value":"#4491","number":4491}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":26822,"title":"fix(gateway): merge rapid TEXT follow-ups during active sessions (#4469)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","tests/gateway/test_active_session_text_merge.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -2961,9 +2961,25 @@ async def handle_message(self, event: MessageEvent) -> None:\n                 merge_pending_message_event(self._pending_messages, session_key, event)\n                 return  # Don't interrupt now - will run after current task completes\n \n-            # Default behavior for non-photo follow-ups: interrupt the running agent\n+            # Default behavior for non-photo follow-ups: interrupt the running agent.\n+            #\n+            # Use merge_text=True so rapid TEXT follow-ups (#4469) accumulate\n+            # into the single pending slot instead of clobbering each other.\n+            # Without merging, three rapid messages \"A\", \"B\", \"C\" land like:\n+            #   _pending_messages[k] = A  (interrupts)\n+            #   _pending_messages[k] = B  (replaces A before consumer reads)\n+            #   _pending_messages[k] = C  (replaces B)\n+            # ...and only \"C\" reaches the next turn.  merge_pending_message_event\n+            # already does the right thing for photo/media bursts; the\n+            # ``merge_text=True`` flag extends that to plain TEXT events.\n+            # Same shape as the Telegram bursty-grace path in gateway/run.py.\n             logger.debug(\"[%s] New message while session %s is active — triggering interrupt\", self.name, session_key)\n-            self._pending_messages[session_key] = event\n+            merge_pending_message_event(\n+                self._pending_messages,\n+                session_key,\n+                event,\n+                merge_text=True,\n+            )\n             # Signal the interrupt (the processing task checks this)\n             self._active_sessions[session_key].set()\n             return  # Don't process now - will be handled after current task finishes"]},{"file":"tests/gateway/test_active_session_text_merge.py","hunks":["@@ -0,0 +1,152 @@\n+\"\"\"Regression test for #4469.\n+\n+When the agent is actively running (session present in\n+``adapter._active_sessions``) and the user fires off multiple TEXT\n+follow-ups in rapid succession, the previous behaviour was a single-slot\n+replacement at ``gateway/platforms/base.py``:\n+\n+    self._pending_messages[session_key] = event\n+\n+So three rapid messages ``A``, ``B``, ``C`` arriving while the agent was\n+still working on the initial turn produced a pending slot containing only\n+``C``; ``A`` and ``B`` were silently dropped.\n+\n+The fix routes the follow-up through ``merge_pending_message_event(...,\n+merge_text=True)`` so TEXT events accumulate into the existing pending\n+event's text instead of clobbering it.  Photo / media bursts continue to\n+merge through the same helper (they always did).\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import asyncio\n+import sys\n+import types\n+from unittest.mock import AsyncMock, MagicMock\n+\n+import pytest\n+\n+# Minimal telegram stub so importing gateway.platforms.base does not pull\n+# in the real python-telegram-bot dependency.\n+_tg = sys.modules.get(\"telegram\") or types.ModuleType(\"telegram\")\n+_tg.constants = sys.modules.get(\"telegram.constants\") or types.ModuleType(\"telegram.constants\")\n+_ct = MagicMock()\n+_ct.PRIVATE = \"private\"\n+_ct.GROUP = \"group\"\n+_ct.SUPERGROUP = \"supergroup\"\n+_tg.constants.ChatType = _ct\n+sys.modules.setdefault(\"telegram\", _tg)\n+sys.modules.setdefault(\"telegram.constants\", _tg.constants)\n+sys.modules.setdefault(\"telegram.ext\", types.ModuleType(\"telegram.ext\"))\n+\n+from gateway.config import Platform, PlatformConfig\n+from gateway.platforms.base import (\n+    BasePlatformAdapter,\n+    MessageEvent,\n+    MessageType,\n+)\n+from gateway.session import SessionSource, build_session_key\n+\n+\n+def _make_event(text: str, chat_id: str = \"12345\") -> MessageEvent:\n+    source = SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=chat_id,\n+        chat_type=\"dm\",\n+        user_id=\"u1\",\n+    )\n+    return MessageEvent(\n+        text=text,\n+        message_type=MessageType.TEXT,\n+        source=source,\n+        message_id=f\"msg-{text[:8]}\",\n+    )\n+\n+\n+def _make_adapter() -> BasePlatformAdapter:\n+    \"\"\"Build a BasePlatformAdapter without running its heavy __init__.\n+\n+    We only need the bits ``handle_message`` touches on the active-session\n+    path: ``_active_sessions``, ``_pending_messages``,\n+    ``_message_handler``, ``_busy_session_handler``, ``config``, ``platform``.\n+    \"\"\"\n+\n+    class _DummyAdapter(BasePlatformAdapter):  # type: ignore[misc]\n+        async def connect(self):\n+            pass\n+\n+        async def disconnect(self):\n+            pass\n+\n+        async def get_chat_info(self, chat_id):\n+            return None\n+\n+        async def send(self, *args, **kwargs):\n+            return MagicMock(success=True, message_id=\"x\", retryable=False)\n+\n+    adapter = object.__new__(_DummyAdapter)\n+    adapter.config = PlatformConfig(enabled=True, token=\"***\")\n+    adapter.platform = Platform.TELEGRAM\n+    adapter._message_handler = AsyncMock(return_value=None)\n+    adapter._busy_session_handler = None\n+    adapter._active_sessions = {}\n+    adapter._pending_messages = {}\n+    adapter._session_tasks = {}\n+    adapter._background_tasks = set()\n+    adapter._post_delivery_callbacks = {}\n+    adapter._expected_cancelled_tasks = set()\n+    adapter._fatal_error_code = None\n+    adapter._fatal_error_message = None\n+    adapter._fatal_error_retryable = True\n+    adapter._fatal_error_handler = None\n+    adapter._running = True\n+    adapter._auto_tts_default = False\n+    adapter._auto_tts_enabled_chats = set()\n+    adapter._auto_tts_disabled_chats = set()\n+    adapter._typing_paused = set()\n+    return adapter\n+\n+\n[redacted:email]\n+async def test_rapid_text_followups_accumulate_instead_of_replacing():\n+    \"\"\"Three rapid TEXT follow-ups during an active session must all\n+    survive in ``adapter._pending_messages[session_key].text``.\"\"\"\n+    adapter = _make_adapter()\n+    first = _make_event(\"part one\")\n+    session_key = build_session_key(first.source)\n+\n+    # Mark the session as active so subsequent messages take the\n+    # \"already running\" branch in handle_message.\n+    adapter._active_sessions[session_key] = asyncio.Event()\n+\n+    second = _make_event(\"part two\")\n+    third = _make_event(\"part three\")\n+\n+    await adapter.handle_message(second)\n+    await adapter.handle_message(third)\n+\n+    # Both rapid follow-ups must be preserved, not just the last one.\n+    pending = adapter._pending_messages[session_key]\n+    assert pending.text == \"part two\\npart three\", (\n+        f\"expected accumulated text, got {pending.text!r}\"\n+    )\n+    # Interrupt event must be signalled exactly like before.\n+    assert adapter._active_sessions[session_key].is_set()\n+\n+\n[redacted:email]\n+async def test_single_followup_is_stored_as_is():\n+    \"\"\"One TEXT follow-up still lands as the event object itself\n+    (no spurious wrapping / mutation) — guards against the merge path\n+    breaking the simple case.\"\"\"\n+    adapter = _make_adapter()\n+    first = _make_event(\"only one\")\n+    session_key = build_session_key(first.source)\n+\n+    adapter._active_sessions[session_key] = asyncio.Event()\n+    await adapter.handle_message(first)\n+\n+    pending = adapter._pending_messages[session_key]\n+    assert pending is first\n+    assert pending.text == \"only one\"\n+    assert adapter._active_sessions[session_key].is_set()\n"]}]},{"kind":"pr","number":28551,"title":"fix(gateway): busy_input_mode queue uses FIFO instead of single-slot overwrite","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4d0f2bd241694e91d1172194f7a0f73d2d585ba6","number":0},{"kind":"commit","value":"4eb89723903072d2af3ff11462a997fcd5de555b","number":0},{"kind":"pr","value":"#33817","number":33817},{"kind":"pr","value":"#28503","number":28503},{"kind":"pr","value":"#33817","number":33817}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":28606,"title":"fix(gateway): pass merge_text=True in queue mode to prevent silent message drops (#28503)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4d0f2bd241694e91d1172194f7a0f73d2d585ba6","number":0},{"kind":"commit","value":"4eb89723903072d2af3ff11462a997fcd5de555b","number":0},{"kind":"pr","value":"#28503","number":28503},{"kind":"pr","value":"#28503","number":28503},{"kind":"pr","value":"#33817","number":33817}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":29231,"title":"fix(gateway): busy queue mode appends to FIFO instead of overwriting (#28503)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28551","number":28551}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":33817,"title":"fix(gateway): use FIFO queue for busy_input_mode pending messages","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_busy_session_ack.py","tests/gateway/test_queue_consumption.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -3328,11 +3328,52 @@ def _agent_has_active_subagents(running_agent: Any) -> bool:\n         except Exception:\n             return False\n \n+    # Hard cap on per-session pending follow-ups for busy_input_mode=queue\n+    # (and the draining/steer-fallback/subagent-demotion paths that share\n+    # this entry point).  Without a cap, a stuck agent + a rapid-fire user\n+    # could grow the overflow list unboundedly.  32 turns of queued\n+    # follow-ups is far beyond any realistic conversational backlog while\n+    # still small enough to never threaten memory.\n+    _BUSY_QUEUE_MAX_PENDING = 32\n+\n     def _queue_or_replace_pending_event(self, session_key: str, event: MessageEvent) -> None:\n         adapter = self.adapters.get(event.source.platform)\n         if not adapter:\n             return\n-        merge_pending_message_event(adapter._pending_messages, session_key, event)\n+        # #28503 — Previously this called ``merge_pending_message_event``\n+        # with the default ``merge_text=False``, which silently OVERWROTE\n+        # the single pending slot when consecutive text messages arrived\n+        # in ``busy_input_mode: queue``. Route through the FIFO\n+        # infrastructure shared with ``/queue`` so each follow-up gets\n+        # its own turn in arrival order. Photo bursts still merge into\n+        # the head slot via ``merge_pending_message_event`` (album\n+        # semantics); everything else appends to the overflow tail.\n+        pending_slot = getattr(adapter, \"_pending_messages\", None)\n+        existing = pending_slot.get(session_key) if isinstance(pending_slot, dict) else None\n+        if existing is not None and (\n+            getattr(existing, \"message_type\", None) == MessageType.PHOTO\n+            or event.message_type == MessageType.PHOTO\n+            or bool(getattr(existing, \"media_urls\", None))\n+            or bool(getattr(event, \"media_urls\", None))\n+        ):\n+            # Preserve photo-burst / media-merge semantics for the head slot.\n+            merge_pending_message_event(\n+                adapter._pending_messages,\n+                session_key,\n+                event,\n+                merge_text=event.message_type == MessageType.TEXT,\n+            )\n+            return\n+\n+        if self._queue_depth(session_key, adapter=adapter) >= self._BUSY_QUEUE_MAX_PENDING:\n+            logger.warning(\n+                \"Dropping busy-mode follow-up for session %s — pending queue at cap (%d).\",\n+                session_key,\n+                self._BUSY_QUEUE_MAX_PENDING,\n+            )\n+            return\n+\n+        self._enqueue_fifo(session_key, event, adapter)\n \n     async def _handle_active_session_busy_message(self, event: MessageEvent, session_key: str) -> bool:\n         # --- Authorization gate (#17775) ---","@@ -7861,12 +7902,15 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                 )\n                 adapter = self.adapters.get(source.platform)\n                 if adapter:\n-                    merge_pending_message_event(\n-                        adapter._pending_messages,\n-                        _quick_key,\n-                        event,\n-                        merge_text=True,\n-                    )\n+                    if self._busy_input_mode == \"queue\":\n+                        self._enqueue_fifo(_quick_key, event, adapter)\n+                    else:\n+                        merge_pending_message_event(\n+                            adapter._pending_messages,\n+                            _quick_key,\n+                            event,\n+                            merge_text=True,\n+                        )\n                 return None\n \n             running_agent = self._running_agents.get(_quick_key)"]},{"file":"tests/gateway/test_busy_session_ack.py","hunks":["@@ -27,6 +27,7 @@\n from gateway.platforms.base import (\n     MessageEvent,\n     MessageType,\n+    Platform,\n     SessionSource,\n     build_session_key,\n )","@@ -66,6 +67,8 @@ def _make_runner():\n     runner._busy_text_mode = \"interrupt\"\n     runner.adapters = {}\n     runner.config = MagicMock()\n+    runner.config.group_sessions_per_user = True\n+    runner.config.thread_sessions_per_user = False\n     runner.session_store = None\n     runner.hooks = MagicMock()\n     runner.hooks.emit = AsyncMock()","@@ -119,6 +122,55 @@ async def test_handle_message_queue_mode_queues_without_interrupt(self):\n         assert sk not in runner._pending_messages\n         running_agent.interrupt.assert_not_called()\n \n+    @pytest.mark.asyncio\n+    async def test_telegram_grace_followups_respect_queue_fifo(self, monkeypatch):\n+        \"\"\"Rapid Telegram text follow-ups in queue mode must not merge.\"\"\"\n+        from gateway.run import GatewayRunner\n+\n+        monkeypatch.setenv(\"HERMES_TELEGRAM_FOLLOWUP_GRACE_SECONDS\", \"3.0\")\n+\n+        runner, _sentinel = _make_runner()\n+        runner._busy_input_mode = \"queue\"\n+        runner._queued_events = {}\n+        adapter = _make_adapter()\n+\n+        source = SessionSource(\n+            platform=Platform.TELEGRAM,\n+            chat_id=\"123\",\n+            chat_type=\"dm\",\n+            user_id=\"user1\",\n+        )\n+        sk = build_session_key(source)\n+        runner.adapters[source.platform] = adapter\n+\n+        agent = MagicMock()\n+        agent.get_activity_summary.return_value = {\n+            \"seconds_since_activity\": 0.0,\n+        }\n+        runner._running_agents[sk] = agent\n+        runner._running_agents_ts[sk] = time.time()\n+\n+        events = [\n+            MessageEvent(\n+                text=text,\n+                message_type=MessageType.TEXT,\n+                source=source,\n+                message_id=f\"m-{idx}\",\n+            )\n+            for idx, text in enumerate((\"first\", \"second\", \"third\"), start=1)\n+        ]\n+\n+        for event in events:\n+            result = await GatewayRunner._handle_message(runner, event)\n+            assert result is None\n+\n+        assert adapter._pending_messages[sk].text == \"first\"\n+        assert [event.text for event in runner._queued_events[sk]] == [\n+            \"second\",\n+            \"third\",\n+        ]\n+        agent.interrupt.assert_not_called()\n+\n     @pytest.mark.asyncio\n     async def test_sends_ack_when_agent_running(self):\n         \"\"\"First message during busy session should get a status ack.\"\"\""]},{"file":"tests/gateway/test_queue_consumption.py","hunks":["@@ -360,3 +360,95 @@ def test_enqueue_preserves_text_no_merging(self):\n             e.text for e in runner._queued_events[session_key]\n         ]\n         assert collected == texts\n+\n+\n+class TestBusyInputModeQueueFifo:\n+    \"\"\"Regression coverage for issue #28503.\n+\n+    ``busy_input_mode: queue`` rapid follow-ups used to silently overwrite\n+    a single pending slot, losing every message except the last. The\n+    runner's busy/queue/steer-fallback entry point now routes through\n+    the same FIFO infrastructure as ``/queue``, so each follow-up gets\n+    its own turn in arrival order.\n+    \"\"\"\n+\n+    def _make_runner_and_adapter(self):\n+        from gateway.run import GatewayRunner\n+\n+        runner = GatewayRunner.__new__(GatewayRunner)\n+        runner._queued_events = {}\n+        adapter = _StubAdapter()\n+        runner.adapters = {Platform.TELEGRAM: adapter}\n+        return runner, adapter\n+\n+    def _text_event(self, text: str) -> MessageEvent:\n+        source = MagicMock(chat_id=\"c1\", platform=Platform.TELEGRAM)\n+        return MessageEvent(\n+            text=text,\n+            message_type=MessageType.TEXT,\n+            source=source,\n+            message_id=f\"m-{text}\",\n+        )\n+\n+    def test_rapid_text_followups_are_queued_in_fifo_order(self):\n+        \"\"\"Five rapid texts in queue mode must all survive (none silently dropped).\"\"\"\n+        runner, adapter = self._make_runner_and_adapter()\n+        session_key = \"telegram:user:fifo\"\n+\n+        texts = [\"one\", \"two\", \"three\", \"four\", \"five\"]\n+        for text in texts:\n+            runner._queue_or_replace_pending_event(session_key, self._text_event(text))\n+\n+        # Head slot keeps the first; overflow keeps the rest in order.\n+        assert adapter._pending_messages[session_key].text == \"one\"\n+        assert [e.text for e in runner._queued_events[session_key]] == [\n+            \"two\",\n+            \"three\",\n+            \"four\",\n+            \"five\",\n+        ]\n+        assert runner._queue_depth(session_key, adapter=adapter) == len(texts)\n+\n+    def test_queue_respects_bounded_cap(self):\n+        \"\"\"Beyond the per-session cap, follow-ups are dropped (with a warning).\"\"\"\n+        from gateway.run import GatewayRunner\n+\n+        runner, adapter = self._make_runner_and_adapter()\n+        session_key = \"telegram:user:cap\"\n+\n+        cap = GatewayRunner._BUSY_QUEUE_MAX_PENDING\n+        for i in range(cap + 5):\n+            runner._queue_or_replace_pending_event(\n+                session_key, self._text_event(f\"msg-{i:03d}\")\n+            )\n+\n+        # Exactly ``cap`` follow-ups retained (head + cap-1 in overflow).\n+        assert runner._queue_depth(session_key, adapter=adapter) == cap\n+        assert adapter._pending_messages[session_key].text == \"msg-000\"\n+        # The last accepted overflow item is msg-{cap-1}.\n+        assert runner._queued_events[session_key][-1].text == f\"msg-{cap - 1:03d}\"\n+\n+    def test_photo_burst_still_merges_in_head_slot(self):\n+        \"\"\"Photo bursts must keep album-merge semantics, not split into N turns.\"\"\"\n+        runner, adapter = self._make_runner_and_adapter()\n+        session_key = \"telegram:user:burst\"\n+\n+        source = MagicMock(chat_id=\"c1\", platform=Platform.TELEGRAM)\n+        for i in range(3):\n+            runner._queue_or_replace_pending_event(\n+                session_key,\n+                MessageEvent(\n+                    text=\"\",\n+                    message_type=MessageType.PHOTO,\n+                    source=source,\n+                    message_id=f\"p-{i}\",\n+                    media_urls=[f\"http://example.com/{i}.jpg\"],\n+                    media_types=[\"image/jpeg\"],\n+                ),\n+            )\n+\n+        # Single merged head event with all three media URLs.\n+        assert session_key not in runner._queued_events or not runner._queued_events[session_key]\n+        head = adapter._pending_messages[session_key]\n+        assert head.message_type == MessageType.PHOTO\n+        assert len(head.media_urls) == 3\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}