Files
hermes-agent/tests/cli/test_slash_command_interrupt.py
T
Teknium 8697471419 test(cli): cover KeyboardInterrupt guard around slash command dispatch
4 tests: KBI during slash command does not set _should_exit; truthy
return keeps session alive; falsy return still sets exit (legit
/exit path); non-KBI exceptions propagate normally.
2026-05-25 05:06:06 -07:00

114 lines
4.2 KiB
Python

"""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")