{"repo":"NousResearch/hermes-agent","public_id":"5142","display_label":"Ctrl+C during slash commands exits session unexpectedly","summary":"A bug where pressing Ctrl+C during slow slash commands exits the entire session rather than just interrupting the command. Two proposed fixes and one bug report address catching KeyboardInterrupt to prevent unintended session termination.","subsystem":"hermes_cli","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":32017,"merged_at":"2026-05-25T12:06:07+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-25T12:06:07+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":5142,"pr_number":32017,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-25T12:06:07+00:00"}],"members":[{"kind":"issue","number":5142,"title":"[Bug] Ctrl+C during slow slash commands exits session instead of interrupting","url":null,"state":"closed"},{"kind":"pr","number":5189,"title":"fix(cli): catch KeyboardInterrupt during slash commands to prevent session exit","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#32017","number":32017}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":32017,"title":"fix(cli): catch KeyboardInterrupt during slash commands to prevent session exit","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/cli/test_slash_command_interrupt.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -14200,11 +14200,19 @@ def process_loop():\n \n                     if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input):\n                         _cprint(f\"\\n⚙️  {user_input}\")\n-                        if not self.process_command(user_input):\n-                            self._should_exit = True\n-                            # Schedule app exit\n-                            if app.is_running:\n-                                app.exit()\n+                        try:\n+                            if not self.process_command(user_input):\n+                                self._should_exit = True\n+                                # Schedule app exit\n+                                if app.is_running:\n+                                    app.exit()\n+                        except KeyboardInterrupt:\n+                            # Ctrl+C during a slow slash command (e.g. /skills browse,\n+                            # /sessions list with a large DB) should interrupt the\n+                            # command and return to the prompt, NOT exit the entire\n+                            # session. Without this guard a KeyboardInterrupt unwinds\n+                            # to the outer prompt_toolkit loop and the session dies.\n+                            _cprint(\"\\n[dim]Command interrupted.[/dim]\")\n                         continue\n                     \n                     # Expand paste references back to full content"]},{"file":"tests/cli/test_slash_command_interrupt.py","hunks":["@@ -0,0 +1,113 @@\n+\"\"\"Tests for the KeyboardInterrupt guard around slash command dispatch.\n+\n+A Ctrl+C during a slow slash command (e.g. /skills browse on a large\n+skill tree, or /sessions list against a multi-GB SQLite DB) used to\n+unwind to the outer prompt_toolkit loop and kill the entire session.\n+The fix wraps `self.process_command(user_input)` in a try/except\n+KeyboardInterrupt so the command aborts but the session survives.\n+\n+These tests verify the contract without spinning up the full\n+prompt_toolkit input loop. We exercise the same try/except by calling\n+through a thin wrapper that mirrors the real dispatch shape.\n+\"\"\"\n+\n+from unittest.mock import MagicMock, patch\n+\n+from cli import HermesCLI\n+\n+\n+def _make_cli():\n+    cli = HermesCLI.__new__(HermesCLI)\n+    cli._should_exit = False\n+    cli.conversation_history = []\n+    cli.agent = None\n+    cli._session_db = None\n+    return cli\n+\n+\n+def _dispatch(cli, user_input: str, process_command_side_effect=None):\n+    \"\"\"Mirror the production dispatch shape from cli.py around line 14236.\n+\n+    Real call site:\n+        if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input):\n+            _cprint(f\"\\\\n⚙️  {user_input}\")\n+            try:\n+                if not self.process_command(user_input):\n+                    self._should_exit = True\n+                    if app.is_running:\n+                        app.exit()\n+            except KeyboardInterrupt:\n+                _cprint(\"\\\\n[dim]Command interrupted.[/dim]\")\n+            continue\n+    \"\"\"\n+    if process_command_side_effect is not None:\n+        with patch.object(cli, \"process_command\", side_effect=process_command_side_effect) as mock_pc:\n+            try:\n+                if not cli.process_command(user_input):\n+                    cli._should_exit = True\n+            except KeyboardInterrupt:\n+                # Mirror production: swallow, do NOT raise.\n+                pass\n+            return mock_pc\n+\n+\n+class TestSlashCommandKeyboardInterrupt:\n+    def test_keyboardinterrupt_in_slash_command_does_not_set_exit(self):\n+        \"\"\"Ctrl+C in the middle of /skills browse must NOT set _should_exit.\n+\n+        Before the fix: KeyboardInterrupt unwinds past the dispatch,\n+        the outer event loop catches it, session dies.\n+        After the fix: KeyboardInterrupt is caught locally, _should_exit\n+        stays False, the prompt loop continues.\n+        \"\"\"\n+        cli = _make_cli()\n+\n+        def raises_keyboard_interrupt(_cmd):\n+            raise KeyboardInterrupt(\"user pressed Ctrl+C during slow command\")\n+\n+        _dispatch(cli, \"/skills browse\", process_command_side_effect=raises_keyboard_interrupt)\n+\n+        assert cli._should_exit is False, (\n+            \"KeyboardInterrupt during slash command must not flag exit\"\n+        )\n+\n+    def test_normal_slash_command_returns_truthy_keeps_session_alive(self):\n+        \"\"\"A successful slash command (returns truthy) must NOT set _should_exit.\"\"\"\n+        cli = _make_cli()\n+\n+        _dispatch(cli, \"/help\", process_command_side_effect=[True])\n+\n+        assert cli._should_exit is False\n+\n+    def test_slash_command_returning_false_sets_exit(self):\n+        \"\"\"The legitimate exit signal — process_command() returning False —\n+        still sets _should_exit. This is the path /exit / /quit use.\"\"\"\n+        cli = _make_cli()\n+\n+        _dispatch(cli, \"/exit\", process_command_side_effect=[False])\n+\n+        assert cli._should_exit is True\n+\n+    def test_other_exceptions_propagate(self):\n+        \"\"\"Only KeyboardInterrupt is caught locally. Other exceptions must\n+        propagate so they show up in logs and the global handler can deal\n+        with them — silently swallowing all exceptions would mask bugs.\"\"\"\n+        cli = _make_cli()\n+\n+        class CustomError(Exception):\n+            pass\n+\n+        def raises_custom(_cmd):\n+            raise CustomError(\"real bug\")\n+\n+        try:\n+            with patch.object(cli, \"process_command\", side_effect=raises_custom):\n+                try:\n+                    if not cli.process_command(\"/something\"):\n+                        cli._should_exit = True\n+                except KeyboardInterrupt:\n+                    pass  # would NOT catch CustomError\n+        except CustomError:\n+            return  # expected — non-KBI exceptions propagate\n+\n+        raise AssertionError(\"CustomError should have propagated\")\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}