mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
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.
331 lines
11 KiB
Python
331 lines
11 KiB
Python
"""Tests for cli.HermesCLI._confirm_destructive_slash.
|
|
|
|
Drives the helper directly via __get__ on a SimpleNamespace stand-in so we
|
|
don't have to construct a full HermesCLI (which requires extensive setup).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import queue
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
|
|
def _bound(fn, instance):
|
|
"""Bind an unbound method to a stand-in instance."""
|
|
return fn.__get__(instance, type(instance))
|
|
|
|
|
|
def _make_self(prompt_response):
|
|
"""Build a minimal stand-in 'self' for _confirm_destructive_slash."""
|
|
from cli import HermesCLI
|
|
|
|
self_ = SimpleNamespace(
|
|
_app=None,
|
|
_prompt_text_input=lambda _prompt: prompt_response,
|
|
_prompt_text_input_modal=lambda **_kw: prompt_response,
|
|
)
|
|
self_._normalize_slash_confirm_choice = _bound(
|
|
HermesCLI._normalize_slash_confirm_choice, self_,
|
|
)
|
|
return self_
|
|
|
|
|
|
def test_gate_off_returns_once_without_prompting():
|
|
"""When approvals.destructive_slash_confirm is False, return 'once'
|
|
immediately (caller proceeds without showing a prompt)."""
|
|
from cli import HermesCLI
|
|
|
|
self_ = _make_self(prompt_response="should not be called")
|
|
|
|
with patch(
|
|
"cli.load_cli_config",
|
|
return_value={"approvals": {"destructive_slash_confirm": False}},
|
|
):
|
|
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
|
"clear", "detail",
|
|
)
|
|
|
|
assert result == "once"
|
|
|
|
|
|
def test_gate_on_choice_once_returns_once():
|
|
"""When the gate is on and the user picks '1', return 'once'."""
|
|
from cli import HermesCLI
|
|
|
|
self_ = _make_self(prompt_response="1")
|
|
|
|
with patch(
|
|
"cli.load_cli_config",
|
|
return_value={"approvals": {"destructive_slash_confirm": True}},
|
|
):
|
|
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
|
"clear", "detail",
|
|
)
|
|
|
|
assert result == "once"
|
|
|
|
|
|
def test_gate_on_choice_cancel_returns_none():
|
|
"""When the user picks '3' (cancel), return None — caller must abort."""
|
|
from cli import HermesCLI
|
|
|
|
self_ = _make_self(prompt_response="3")
|
|
|
|
with patch(
|
|
"cli.load_cli_config",
|
|
return_value={"approvals": {"destructive_slash_confirm": True}},
|
|
):
|
|
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
|
"clear", "detail",
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_gate_on_no_input_returns_none():
|
|
"""No input (None / EOF / Ctrl-C) treated as cancel."""
|
|
from cli import HermesCLI
|
|
|
|
self_ = _make_self(prompt_response=None)
|
|
|
|
with patch(
|
|
"cli.load_cli_config",
|
|
return_value={"approvals": {"destructive_slash_confirm": True}},
|
|
):
|
|
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
|
"clear", "detail",
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_gate_on_unknown_choice_returns_none():
|
|
"""Garbage input is treated as cancel — fail safe, don't destroy state."""
|
|
from cli import HermesCLI
|
|
|
|
self_ = _make_self(prompt_response="maybe")
|
|
|
|
with patch(
|
|
"cli.load_cli_config",
|
|
return_value={"approvals": {"destructive_slash_confirm": True}},
|
|
):
|
|
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
|
"clear", "detail",
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_gate_on_choice_always_persists_and_returns_always():
|
|
"""User picks 'always' → returns 'always' AND
|
|
save_config_value('approvals.destructive_slash_confirm', False) was called."""
|
|
from cli import HermesCLI
|
|
|
|
self_ = _make_self(prompt_response="2")
|
|
|
|
saves = []
|
|
def _fake_save(key, value):
|
|
saves.append((key, value))
|
|
return True
|
|
|
|
with patch(
|
|
"cli.load_cli_config",
|
|
return_value={"approvals": {"destructive_slash_confirm": True}},
|
|
), patch("cli.save_config_value", _fake_save):
|
|
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
|
"clear", "detail",
|
|
)
|
|
|
|
assert result == "always"
|
|
assert ("approvals.destructive_slash_confirm", False) in saves
|
|
|
|
|
|
def test_gate_default_true_when_config_missing():
|
|
"""If load_cli_config raises or returns malformed data, treat as
|
|
'gate on' (default safe) — must prompt."""
|
|
from cli import HermesCLI
|
|
|
|
self_ = _make_self(prompt_response="3") # cancel
|
|
|
|
with patch("cli.load_cli_config", side_effect=Exception("boom")):
|
|
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
|
"clear", "detail",
|
|
)
|
|
|
|
# Got prompted (returned None from cancel) — meaning the gate was
|
|
# treated as on despite the config error. If the gate had been off
|
|
# this would have returned 'once' without consulting the prompt.
|
|
assert result is None
|
|
|
|
|
|
def test_slash_confirm_modal_number_selection_submits_without_raw_input():
|
|
"""Pressing 2 in the TUI modal should resolve to Always Approve directly."""
|
|
from cli import HermesCLI
|
|
|
|
q = queue.Queue()
|
|
self_ = SimpleNamespace(
|
|
_slash_confirm_state={
|
|
"choices": [
|
|
("once", "Approve Once", "proceed once"),
|
|
("always", "Always Approve", "persist opt-out"),
|
|
("cancel", "Cancel", "abort"),
|
|
],
|
|
"selected": 0,
|
|
"response_queue": q,
|
|
},
|
|
_slash_confirm_deadline=123,
|
|
_invalidate=lambda: None,
|
|
)
|
|
|
|
_bound(HermesCLI._submit_slash_confirm_response, self_)("always")
|
|
|
|
assert q.get_nowait() == "always"
|
|
assert self_._slash_confirm_state is None
|
|
assert self_._slash_confirm_deadline == 0
|
|
|
|
|
|
def test_slash_confirm_display_fragments_include_choice_mapping():
|
|
"""The modal itself must show what 1/2/3 mean, not only 'Choice [1/2/3]'."""
|
|
from cli import HermesCLI
|
|
|
|
self_ = SimpleNamespace(
|
|
_slash_confirm_state={
|
|
"title": "⚠️ /new — destroys conversation state",
|
|
"detail": "This starts a fresh session.",
|
|
"choices": [
|
|
("once", "Approve Once", "proceed once"),
|
|
("always", "Always Approve", "persist opt-out"),
|
|
("cancel", "Cancel", "abort"),
|
|
],
|
|
"selected": 1,
|
|
},
|
|
)
|
|
|
|
fragments = _bound(HermesCLI._get_slash_confirm_display_fragments, self_)()
|
|
rendered = "".join(fragment for _style, fragment in fragments)
|
|
|
|
assert "[1] Approve Once" in rendered
|
|
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
|