diff --git a/tests/cli/test_slash_command_interrupt.py b/tests/cli/test_slash_command_interrupt.py new file mode 100644 index 00000000000..37e38c8c5f2 --- /dev/null +++ b/tests/cli/test_slash_command_interrupt.py @@ -0,0 +1,113 @@ +"""Tests for the KeyboardInterrupt guard around slash command dispatch. + +A Ctrl+C during a slow slash command (e.g. /skills browse on a large +skill tree, or /sessions list against a multi-GB SQLite DB) used to +unwind to the outer prompt_toolkit loop and kill the entire session. +The fix wraps `self.process_command(user_input)` in a try/except +KeyboardInterrupt so the command aborts but the session survives. + +These tests verify the contract without spinning up the full +prompt_toolkit input loop. We exercise the same try/except by calling +through a thin wrapper that mirrors the real dispatch shape. +""" + +from unittest.mock import MagicMock, patch + +from cli import HermesCLI + + +def _make_cli(): + cli = HermesCLI.__new__(HermesCLI) + cli._should_exit = False + cli.conversation_history = [] + cli.agent = None + cli._session_db = None + return cli + + +def _dispatch(cli, user_input: str, process_command_side_effect=None): + """Mirror the production dispatch shape from cli.py around line 14236. + + Real call site: + if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input): + _cprint(f"\\n⚙️ {user_input}") + try: + if not self.process_command(user_input): + self._should_exit = True + if app.is_running: + app.exit() + except KeyboardInterrupt: + _cprint("\\n[dim]Command interrupted.[/dim]") + continue + """ + if process_command_side_effect is not None: + with patch.object(cli, "process_command", side_effect=process_command_side_effect) as mock_pc: + try: + if not cli.process_command(user_input): + cli._should_exit = True + except KeyboardInterrupt: + # Mirror production: swallow, do NOT raise. + pass + return mock_pc + + +class TestSlashCommandKeyboardInterrupt: + def test_keyboardinterrupt_in_slash_command_does_not_set_exit(self): + """Ctrl+C in the middle of /skills browse must NOT set _should_exit. + + Before the fix: KeyboardInterrupt unwinds past the dispatch, + the outer event loop catches it, session dies. + After the fix: KeyboardInterrupt is caught locally, _should_exit + stays False, the prompt loop continues. + """ + cli = _make_cli() + + def raises_keyboard_interrupt(_cmd): + raise KeyboardInterrupt("user pressed Ctrl+C during slow command") + + _dispatch(cli, "/skills browse", process_command_side_effect=raises_keyboard_interrupt) + + assert cli._should_exit is False, ( + "KeyboardInterrupt during slash command must not flag exit" + ) + + def test_normal_slash_command_returns_truthy_keeps_session_alive(self): + """A successful slash command (returns truthy) must NOT set _should_exit.""" + cli = _make_cli() + + _dispatch(cli, "/help", process_command_side_effect=[True]) + + assert cli._should_exit is False + + def test_slash_command_returning_false_sets_exit(self): + """The legitimate exit signal — process_command() returning False — + still sets _should_exit. This is the path /exit / /quit use.""" + cli = _make_cli() + + _dispatch(cli, "/exit", process_command_side_effect=[False]) + + assert cli._should_exit is True + + def test_other_exceptions_propagate(self): + """Only KeyboardInterrupt is caught locally. Other exceptions must + propagate so they show up in logs and the global handler can deal + with them — silently swallowing all exceptions would mask bugs.""" + cli = _make_cli() + + class CustomError(Exception): + pass + + def raises_custom(_cmd): + raise CustomError("real bug") + + try: + with patch.object(cli, "process_command", side_effect=raises_custom): + try: + if not cli.process_command("/something"): + cli._should_exit = True + except KeyboardInterrupt: + pass # would NOT catch CustomError + except CustomError: + return # expected — non-KBI exceptions propagate + + raise AssertionError("CustomError should have propagated")