2026-05-25 03:53:53 -07:00
|
|
|
|
"""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.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
chore: prune unused imports and duplicate import redefinitions
Remove unused imports (F401) and duplicate/shadowed import
redefinitions (F811) across the codebase using ruff's safe
autofixes. No behavioral changes -- imports only.
- ~1400 safe autofixes applied across 644 files (net -1072 lines)
- __init__.py re-exports preserved (excluded from F401 removal so
public re-export surfaces stay intact)
- Re-exports that are imported or monkeypatched by tests but look
unused in their defining module are kept with explicit # noqa:
F401 (gateway/run.py load_dotenv; run_agent re-exports from
agent.message_sanitization, agent.context_compressor,
agent.retry_utils, agent.prompt_builder, agent.process_bootstrap,
agent.codex_responses_adapter)
- Unsafe F841 (unused-variable) fixes deliberately skipped -- those
can change behavior when the RHS has side effects
- ruff lints remain disabled in pyproject.toml (only PLW1514 is
selected); this is a one-time cleanup, not a config change
Verification:
- python -m compileall: clean
- pytest --collect-only: all 27161 tests collect (zero import errors)
- core entry points import clean (run_agent, model_tools, cli,
toolsets, hermes_state, batch_runner, gateway)
- static scan: every name any test imports directly from an edited
module still resolves
2026-05-29 02:04:58 +05:30
|
|
|
|
from unittest.mock import patch
|
2026-05-25 03:53:53 -07:00
|
|
|
|
|
|
|
|
|
|
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")
|