mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-08 08:11:38 +00:00
fix(cli): add inline --yes/now skip for destructive slash commands (#30768)
Issue #30768 reports that on native Windows PowerShell the destructive-slash confirmation modal renders but never registers keypresses, leaving the user unable to confirm or cancel /reset, /new, /clear, or /undo. The modal works on macOS, Linux, and WSL; PR #23907 (merged May 11) replaced the daemon-thread input() pattern with a prompt_toolkit-native keybinding modal but the win32 input pipeline apparently doesn't dispatch keys to the filter-conditioned handlers. The modal investigation is ongoing. This change ships the immediate escape hatch: append `now`, `--yes`, or `-y` to any destructive slash command to bypass the modal and run the action immediately. Works on every platform without touching the broken Windows code path. /reset now -> reset, no modal /new --yes my-session -> new session titled "my-session", no modal /clear -y -> clear, no modal /undo -y -> undo, no modal The default behavior (modal prompts when approvals.destructive_slash_confirm is True) is unchanged for users who don't pass a skip token. Implementation: - New classmethod HermesCLI._split_destructive_skip(text) -> (remainder, skip) parses a destructive-slash command string, strips the leading "/cmd" word and any recognized skip tokens (case-insensitive exact match, not substring), and reports whether a skip was requested. - HermesCLI._confirm_destructive_slash gains an optional cmd_original= arg. When the arg contains a skip token, it returns "once" immediately — before the gate check and before any modal rendering. - The /clear, /new, /undo handlers in process_command pass cmd_original through. /new additionally uses _split_destructive_skip to strip skip tokens from the remaining text before deriving the session title, so "/new now My Session" yields title="My Session" (not "now My Session"). Tests: - 7 new unit tests in tests/cli/test_destructive_slash_confirm.py covering the helper (recognized tokens, command-word stripping, case-insensitive exact match, None/empty input) and the modal bypass (now and --yes both skip; no-skip-token still consults the modal). - 3 new integration tests in tests/cli/test_destructive_slash_inline_skip_e2e.py driving HermesCLI.process_command end-to-end and asserting (a) new_session is invoked, (b) the modal is never reached, (c) the skip token does not leak into the session title, and (d) the no-skip-token path still reaches the modal as a sanity check that we haven't accidentally short-circuited the normal flow. All 31 tests across the destructive-slash test surface pass. Docs: - website/docs/reference/slash-commands.md documents the new flags both in the destructive-commands table and the dedicated approval section, with a link back to issue #30768 explaining why the escape hatch exists.
This commit is contained in:
parent
99a7ecc335
commit
8e68426981
4 changed files with 318 additions and 4 deletions
|
|
@ -209,3 +209,123 @@ def test_slash_confirm_display_fragments_include_choice_mapping():
|
|||
assert "[2] Always Approve" in rendered
|
||||
assert "[3] Cancel" in rendered
|
||||
assert "Type 1/2/3" in rendered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline-skip escape hatch (issue #30768)
|
||||
#
|
||||
# Users on platforms where the prompt_toolkit modal doesn't dispatch keys
|
||||
# (currently native Windows PowerShell) need a way to bypass the confirmation
|
||||
# without flipping the config gate. ``/reset now``, ``/new --yes``, ``/clear
|
||||
# -y`` all skip the modal and return "once" immediately.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_split_destructive_skip_recognized_tokens():
|
||||
"""``now``, ``--yes``, and ``-y`` are recognized as skip tokens."""
|
||||
from cli import HermesCLI
|
||||
|
||||
assert HermesCLI._split_destructive_skip("/reset now") == ("", True)
|
||||
assert HermesCLI._split_destructive_skip("/clear --yes") == ("", True)
|
||||
assert HermesCLI._split_destructive_skip("/undo -y") == ("", True)
|
||||
|
||||
|
||||
def test_split_destructive_skip_strips_command_word():
|
||||
"""Leading ``/cmd`` token is stripped; remaining args survive."""
|
||||
from cli import HermesCLI
|
||||
|
||||
assert HermesCLI._split_destructive_skip("/new My title") == ("My title", False)
|
||||
assert HermesCLI._split_destructive_skip("/new --yes My title") == ("My title", True)
|
||||
|
||||
|
||||
def test_split_destructive_skip_case_insensitive():
|
||||
"""Token matching is case-insensitive but not a substring match."""
|
||||
from cli import HermesCLI
|
||||
|
||||
assert HermesCLI._split_destructive_skip("/new NOW") == ("", True)
|
||||
# Substring match must NOT trigger — "Now-Title" is a literal title token.
|
||||
assert HermesCLI._split_destructive_skip("/new Now-Title") == ("Now-Title", False)
|
||||
|
||||
|
||||
def test_split_destructive_skip_handles_empty_and_none():
|
||||
"""Defensive against missing/empty input."""
|
||||
from cli import HermesCLI
|
||||
|
||||
assert HermesCLI._split_destructive_skip(None) == ("", False)
|
||||
assert HermesCLI._split_destructive_skip("") == ("", False)
|
||||
assert HermesCLI._split_destructive_skip(" ") == ("", False)
|
||||
|
||||
|
||||
def test_confirm_destructive_slash_now_skips_modal():
|
||||
"""``/reset now`` skips the modal even when the gate is on."""
|
||||
from cli import HermesCLI
|
||||
|
||||
# Build a prompt stub that fails the test if invoked — proving the modal
|
||||
# was never reached.
|
||||
def _explode(**_kw):
|
||||
raise AssertionError("modal must not be invoked when inline-skip present")
|
||||
|
||||
self_ = SimpleNamespace(
|
||||
_app=None,
|
||||
_prompt_text_input_modal=_explode,
|
||||
)
|
||||
self_._normalize_slash_confirm_choice = _bound(
|
||||
HermesCLI._normalize_slash_confirm_choice, self_,
|
||||
)
|
||||
self_._split_destructive_skip = HermesCLI._split_destructive_skip # classmethod
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
):
|
||||
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
||||
"new", "detail", cmd_original="/reset now",
|
||||
)
|
||||
|
||||
assert result == "once"
|
||||
|
||||
|
||||
def test_confirm_destructive_slash_yes_flag_skips_modal():
|
||||
"""``--yes`` flag is equivalent to ``now``."""
|
||||
from cli import HermesCLI
|
||||
|
||||
def _explode(**_kw):
|
||||
raise AssertionError("modal must not be invoked when --yes present")
|
||||
|
||||
self_ = SimpleNamespace(
|
||||
_app=None,
|
||||
_prompt_text_input_modal=_explode,
|
||||
)
|
||||
self_._normalize_slash_confirm_choice = _bound(
|
||||
HermesCLI._normalize_slash_confirm_choice, self_,
|
||||
)
|
||||
self_._split_destructive_skip = HermesCLI._split_destructive_skip
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
):
|
||||
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
||||
"new", "detail", cmd_original="/new --yes My Session",
|
||||
)
|
||||
|
||||
assert result == "once"
|
||||
|
||||
|
||||
def test_confirm_destructive_slash_no_skip_token_still_prompts():
|
||||
"""Without a skip token the gate-on path still consults the modal."""
|
||||
from cli import HermesCLI
|
||||
|
||||
self_ = _make_self(prompt_response="3") # cancel
|
||||
self_._split_destructive_skip = HermesCLI._split_destructive_skip
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
):
|
||||
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
||||
"new", "detail", cmd_original="/new My Session",
|
||||
)
|
||||
|
||||
# Prompt was reached and returned cancel → None.
|
||||
assert result is None
|
||||
|
|
|
|||
129
tests/cli/test_destructive_slash_inline_skip_e2e.py
Normal file
129
tests/cli/test_destructive_slash_inline_skip_e2e.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""End-to-end integration test for the destructive-slash inline-skip path.
|
||||
|
||||
Drives ``HermesCLI.process_command("/reset now")`` against a minimal stand-in
|
||||
and verifies:
|
||||
|
||||
1. ``new_session`` was invoked (the command actually ran)
|
||||
2. ``_prompt_text_input_modal`` was NOT invoked (modal bypassed)
|
||||
3. The skip token did not leak into the session title
|
||||
|
||||
This is the regression test for issue #30768 — the inline-skip escape hatch
|
||||
must work without ever touching the modal, on every platform.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _make_cli_stub():
|
||||
"""Build a minimal HermesCLI-shaped object that can run ``process_command``
|
||||
for the destructive-slash branches without spinning up a real TUI."""
|
||||
from cli import HermesCLI
|
||||
|
||||
new_session_calls = []
|
||||
|
||||
def _capture_new_session(self_, title=None, silent=False):
|
||||
new_session_calls.append({"title": title, "silent": silent})
|
||||
|
||||
self_ = SimpleNamespace(
|
||||
_app=None,
|
||||
_prompt_text_input_modal=lambda **_kw: (_ for _ in ()).throw(
|
||||
AssertionError("modal must not be invoked when inline-skip token present")
|
||||
),
|
||||
new_session=lambda **kw: _capture_new_session(self_, **kw),
|
||||
# Stub out side-effects the destructive-slash branches reach for.
|
||||
console=SimpleNamespace(clear=lambda: None),
|
||||
compact=False,
|
||||
model="stub-model",
|
||||
session_id="stub-session",
|
||||
enabled_toolsets=[],
|
||||
_pending_title=None,
|
||||
_session_db=None,
|
||||
)
|
||||
# Bind the methods we need under test.
|
||||
self_._split_destructive_skip = HermesCLI._split_destructive_skip
|
||||
self_._confirm_destructive_slash = HermesCLI._confirm_destructive_slash.__get__(
|
||||
self_, type(self_)
|
||||
)
|
||||
self_.process_command = HermesCLI.process_command.__get__(self_, type(self_))
|
||||
return self_, new_session_calls
|
||||
|
||||
|
||||
def test_reset_now_invokes_new_session_without_modal():
|
||||
"""``/reset now`` runs ``new_session`` and never touches the modal."""
|
||||
self_, calls = _make_cli_stub()
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
):
|
||||
self_.process_command("/reset now")
|
||||
|
||||
assert calls, "new_session was never invoked"
|
||||
# The /new branch passes title=None when there's no non-skip remainder.
|
||||
assert calls[0]["title"] is None
|
||||
|
||||
|
||||
def test_new_yes_with_title_preserves_title():
|
||||
"""``/new --yes My Session`` runs ``new_session(title='My Session')``."""
|
||||
self_, calls = _make_cli_stub()
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
):
|
||||
self_.process_command("/new --yes My Session")
|
||||
|
||||
assert calls, "new_session was never invoked"
|
||||
assert calls[0]["title"] == "My Session"
|
||||
|
||||
|
||||
def test_new_without_skip_token_still_consults_modal():
|
||||
"""``/new My Session`` (no skip token) must reach the modal.
|
||||
|
||||
Sanity check that we haven't accidentally short-circuited the normal path.
|
||||
"""
|
||||
from cli import HermesCLI
|
||||
|
||||
new_session_calls = []
|
||||
modal_calls = []
|
||||
|
||||
def _capture_new_session(self_, title=None, silent=False):
|
||||
new_session_calls.append({"title": title, "silent": silent})
|
||||
|
||||
def _record_modal(**kw):
|
||||
modal_calls.append(kw)
|
||||
# Simulate user cancelling so new_session is not called.
|
||||
return "3"
|
||||
|
||||
self_ = SimpleNamespace(
|
||||
_app=None,
|
||||
_prompt_text_input_modal=_record_modal,
|
||||
new_session=lambda **kw: _capture_new_session(self_, **kw),
|
||||
console=SimpleNamespace(clear=lambda: None),
|
||||
compact=False,
|
||||
model="stub-model",
|
||||
session_id="stub-session",
|
||||
enabled_toolsets=[],
|
||||
_pending_title=None,
|
||||
_session_db=None,
|
||||
)
|
||||
self_._split_destructive_skip = HermesCLI._split_destructive_skip
|
||||
self_._normalize_slash_confirm_choice = HermesCLI._normalize_slash_confirm_choice.__get__(
|
||||
self_, type(self_)
|
||||
)
|
||||
self_._confirm_destructive_slash = HermesCLI._confirm_destructive_slash.__get__(
|
||||
self_, type(self_)
|
||||
)
|
||||
self_.process_command = HermesCLI.process_command.__get__(self_, type(self_))
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
):
|
||||
self_.process_command("/new My Session")
|
||||
|
||||
assert modal_calls, "modal must be reached when no skip token is present"
|
||||
assert not new_session_calls, "user cancelled — new_session must not run"
|
||||
Loading…
Add table
Add a link
Reference in a new issue