hermes-agent/tests/cli/test_destructive_slash_confirm.py
2026-05-11 10:02:03 -07:00

211 lines
6.4 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