{"repo":"NousResearch/hermes-agent","public_id":"11118","display_label":"Gateway busy-session input handling: queue vs interrupt modes","summary":"These items all concern how the gateway handles messages that arrive while an agent session is busy. They cover respecting the queue setting during normal runs, distinguishing queue and interrupt acknowledgements, and adding per-message controls with a halt heuristic.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":14762,"merged_at":"2026-04-23T22:12:43+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-23T22:12:43+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":11118,"pr_number":14762,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-23T22:12:43+00:00"},{"issue_number":13403,"pr_number":14762,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-23T22:12:43+00:00"}],"members":[{"kind":"issue","number":11118,"title":"[Feature]: Distinguish queue-mode vs interrupt-mode busy-session acknowledgements in the gateway","url":null,"state":"closed"},{"kind":"pr","number":14762,"title":"fix(gateway): respect busy_input_mode=queue during normal agent runs","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","scripts/release.py","tests/gateway/test_busy_session_ack.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -1551,27 +1551,23 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session\n             )\n             return True\n \n-        # --- Normal busy case (agent actively running a task) ---\n-        # The user sent a message while the agent is working.  Interrupt the\n-        # agent immediately so it stops the current tool-calling loop and\n-        # processes the new message.  The pending message is stored in the\n-        # adapter so the base adapter picks it up once the interrupted run\n-        # returns.  A brief ack tells the user what's happening (debounced\n-        # to avoid spam when they fire multiple messages quickly).\n-\n+        # Normal busy case (agent actively running a task)\n         adapter = self.adapters.get(event.source.platform)\n         if not adapter:\n             return False  # let default path handle it\n \n         # Store the message so it's processed as the next turn after the\n-        # interrupt causes the current run to exit.\n+        # current run finishes (or is interrupted).\n         from gateway.platforms.base import merge_pending_message_event\n         merge_pending_message_event(adapter._pending_messages, session_key, event)\n \n-        # Interrupt the running agent — this aborts in-flight tool calls and\n-        # causes the agent loop to exit at the next check point.\n+        is_queue_mode = self._busy_input_mode == \"queue\"\n+\n+        # If not in queue mode, interrupt the running agent immediately.\n+        # This aborts in-flight tool calls and causes the agent loop to exit\n+        # at the next check point.\n         running_agent = self._running_agents.get(session_key)\n-        if running_agent and running_agent is not _AGENT_PENDING_SENTINEL:\n+        if not is_queue_mode and running_agent and running_agent is not _AGENT_PENDING_SENTINEL:\n             try:\n                 running_agent.interrupt(event.text)\n             except Exception:","@@ -1583,7 +1579,7 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session\n         now = time.time()\n         last_ack = self._busy_ack_ts.get(session_key, 0)\n         if now - last_ack < _BUSY_ACK_COOLDOWN:\n-            return True  # interrupt sent, ack already delivered recently\n+            return True  # interrupt sent (if not queue), ack already delivered recently\n \n         self._busy_ack_ts[session_key] = now\n ","@@ -1608,10 +1604,16 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session\n                 pass\n \n         status_detail = f\" ({', '.join(status_parts)})\" if status_parts else \"\"\n-        message = (\n-            f\"⚡ Interrupting current task{status_detail}. \"\n-            f\"I'll respond to your message shortly.\"\n-        )\n+        if is_queue_mode:\n+            message = (\n+                f\"⏳ Queued for the next turn{status_detail}. \"\n+                f\"I'll respond once the current task finishes.\"\n+            )\n+        else:\n+            message = (\n+                f\"⚡ Interrupting current task{status_detail}. \"\n+                f\"I'll respond to your message shortly.\"\n+            )\n \n         thread_meta = {\"thread_id\": event.source.thread_id} if event.source.thread_id else None\n         try:"]},{"file":"scripts/release.py","hunks":["@@ -165,6 +165,7 @@\n     \"[redacted:email]\": \"Salt-555\",\n     \"[redacted:email]\": \"satelerd\",\n     \"[redacted:email]\": \"nummanali\",\n+    \"[redacted:email]\": \"whitehatjr1001\",\n     \"0xNyk@users.noreply.github.com\": \"0xNyk\",\n     \"[redacted:email]\": \"0xNyk\",\n     \"[redacted:email]\": \"buray\","]},{"file":"tests/gateway/test_busy_session_ack.py","hunks":["@@ -95,6 +95,7 @@ class TestBusySessionAck:\n     async def test_sends_ack_when_agent_running(self):\n         \"\"\"First message during busy session should get a status ack.\"\"\"\n         runner, sentinel = _make_runner()\n+        runner._busy_input_mode = \"interrupt\"\n         adapter = _make_adapter()\n \n         event = _make_event(text=\"Are you working?\")","@@ -127,16 +128,42 @@ async def test_sends_ack_when_agent_running(self):\n         assert \"Interrupting\" in content or \"respond\" in content\n         assert \"/stop\" not in content  # no need — we ARE interrupting\n \n-        # Verify message was queued in adapter pending\n-        assert sk in adapter._pending_messages\n-\n         # Verify agent interrupt was called\n         agent.interrupt.assert_called_once_with(\"Are you working?\")\n \n+    @pytest.mark.asyncio\n+    async def test_queue_mode_suppresses_interrupt_and_updates_ack(self):\n+        \"\"\"When busy_input_mode is 'queue', message is queued WITHOUT interrupt.\"\"\"\n+        runner, sentinel = _make_runner()\n+        runner._busy_input_mode = \"queue\"\n+        adapter = _make_adapter()\n+\n+        event = _make_event(text=\"Add this to queue\")\n+        sk = build_session_key(event.source)\n+        runner.adapters[event.source.platform] = adapter\n+\n+        agent = MagicMock()\n+        runner._running_agents[sk] = agent\n+\n+        with patch(\"gateway.run.merge_pending_message_event\"):\n+            await runner._handle_active_session_busy_message(event, sk)\n+\n+        # VERIFY: Agent was NOT interrupted\n+        agent.interrupt.assert_not_called()\n+\n+        # VERIFY: Ack sent with queue-specific wording\n+        adapter._send_with_retry.assert_called_once()\n+        call_kwargs = adapter._send_with_retry.call_args\n+        content = call_kwargs.kwargs.get(\"content\") or call_kwargs[1].get(\"content\", \"\")\n+        assert \"Queued for the next turn\" in content\n+        assert \"respond once the current task finishes\" in content\n+        assert \"Interrupting\" not in content\n+\n     @pytest.mark.asyncio\n     async def test_debounce_suppresses_rapid_acks(self):\n         \"\"\"Second message within 30s should NOT send another ack.\"\"\"\n         runner, sentinel = _make_runner()\n+        runner._busy_input_mode = \"interrupt\"\n         adapter = _make_adapter()\n \n         event1 = _make_event(text=\"hello?\")","@@ -172,13 +199,14 @@ async def test_debounce_suppresses_rapid_acks(self):\n         assert result2 is True\n         assert adapter._send_with_retry.call_count == 1  # still 1, no new ack\n \n-        # But interrupt should still be called for both\n+        # But interrupt should still be called for both (since we are in interrupt mode)\n         assert agent.interrupt.call_count == 2\n \n     @pytest.mark.asyncio\n     async def test_ack_after_cooldown_expires(self):\n         \"\"\"After 30s cooldown, a new message should send a fresh ack.\"\"\"\n         runner, sentinel = _make_runner()\n+        runner._busy_input_mode = \"interrupt\"\n         adapter = _make_adapter()\n \n         event = _make_event(text=\"hello?\")","@@ -212,6 +240,7 @@ async def test_ack_after_cooldown_expires(self):\n     async def test_includes_status_detail(self):\n         \"\"\"Ack message should include iteration and tool info when available.\"\"\"\n         runner, sentinel = _make_runner()\n+        runner._busy_input_mode = \"interrupt\"\n         adapter = _make_adapter()\n \n         event = _make_event(text=\"yo\")","@@ -243,6 +272,7 @@ async def test_draining_still_works(self):\n         \"\"\"Draining case should still produce the drain-specific message.\"\"\"\n         runner, sentinel = _make_runner()\n         runner._draining = True\n+        runner._busy_input_mode = \"interrupt\"\n         adapter = _make_adapter()\n \n         event = _make_event(text=\"hello\")","@@ -264,6 +294,7 @@ async def test_draining_still_works(self):\n     async def test_pending_sentinel_no_interrupt(self):\n         \"\"\"When agent is PENDING_SENTINEL, don't call interrupt (it has no method).\"\"\"\n         runner, sentinel = _make_runner()\n+        runner._busy_input_mode = \"interrupt\"\n         adapter = _make_adapter()\n \n         event = _make_event(text=\"hey\")\n"]}]},{"kind":"pr","number":21080,"title":"feat(gateway): per-message busy-session controls — inline buttons + multilingual halt heuristic","url":null,"state":"open","close_reason":{"category":"merged_via","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}