b4fbd6fe9f
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
Build Skills Index / trigger-deploy (push) Waiting to run
CI / Deny unrelated histories (push) Has been skipped
CI / CI timing report (push) Blocked by required conditions
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / All required checks pass (push) Waiting to run
114 lines
4.2 KiB
Python
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 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")
|