/ tests / hermes_cli / test_argparse_flag_propagation.py
test_argparse_flag_propagation.py
  1  """Tests for parent→subparser flag propagation.
  2  
  3  When flags like --yolo, -w, -s exist on both the parent parser and the 'chat'
  4  subparser, placing the flag BEFORE the subcommand (e.g. 'hermes --yolo chat')
  5  must not silently drop the flag value.
  6  
  7  Regression test for: argparse subparser default=False overwriting parent's
  8  parsed True when the same argument is defined on both parsers.
  9  
 10  Fix: chat subparser uses default=argparse.SUPPRESS for all duplicated flags,
 11  so the subparser only sets the attribute when the user explicitly provides it.
 12  """
 13  
 14  import argparse
 15  import os
 16  import sys
 17  from unittest.mock import patch
 18  
 19  import pytest
 20  
 21  
 22  def _build_parser():
 23      """Build the hermes argument parser from the real code.
 24  
 25      We import the real main() and extract the parser it builds.
 26      Since main() is a large function that does much more than parse args,
 27      we replicate just the parser structure here to avoid side effects.
 28      """
 29      parser = argparse.ArgumentParser(prog="hermes")
 30      parser.add_argument("--resume", "-r", metavar="SESSION", default=None)
 31      parser.add_argument(
 32          "--continue", "-c", dest="continue_last", nargs="?",
 33          const=True, default=None, metavar="SESSION_NAME",
 34      )
 35      parser.add_argument("--worktree", "-w", action="store_true", default=False)
 36      parser.add_argument("--skills", "-s", action="append", default=None)
 37      parser.add_argument("--yolo", action="store_true", default=False)
 38      parser.add_argument("--pass-session-id", action="store_true", default=False)
 39  
 40      subparsers = parser.add_subparsers(dest="command")
 41      chat = subparsers.add_parser("chat")
 42      # These MUST use argparse.SUPPRESS to avoid overwriting parent values
 43      chat.add_argument("--yolo", action="store_true",
 44                        default=argparse.SUPPRESS)
 45      chat.add_argument("--worktree", "-w", action="store_true",
 46                        default=argparse.SUPPRESS)
 47      chat.add_argument("--skills", "-s", action="append",
 48                        default=argparse.SUPPRESS)
 49      chat.add_argument("--pass-session-id", action="store_true",
 50                        default=argparse.SUPPRESS)
 51      chat.add_argument("--resume", "-r", metavar="SESSION_ID",
 52                        default=argparse.SUPPRESS)
 53      chat.add_argument(
 54          "--continue", "-c", dest="continue_last", nargs="?",
 55          const=True, default=argparse.SUPPRESS, metavar="SESSION_NAME",
 56      )
 57      return parser
 58  
 59  
 60  class TestYoloEnvVar:
 61      """Verify --yolo sets HERMES_YOLO_MODE regardless of flag position.
 62  
 63      This tests the actual cmd_chat logic pattern (getattr → os.environ).
 64      """
 65  
 66      @pytest.fixture(autouse=True)
 67      def _clean_env(self):
 68          os.environ.pop("HERMES_YOLO_MODE", None)
 69          yield
 70          os.environ.pop("HERMES_YOLO_MODE", None)
 71  
 72      def _simulate_cmd_chat_yolo_check(self, args):
 73          """Replicate the exact check from cmd_chat in main.py."""
 74          if getattr(args, "yolo", False):
 75              os.environ["HERMES_YOLO_MODE"] = "1"
 76  
 77      def test_yolo_before_chat_sets_env(self):
 78          parser = _build_parser()
 79          args = parser.parse_args(["--yolo", "chat"])
 80          self._simulate_cmd_chat_yolo_check(args)
 81          assert os.environ.get("HERMES_YOLO_MODE") == "1"
 82  
 83      def test_yolo_after_chat_sets_env(self):
 84          parser = _build_parser()
 85          args = parser.parse_args(["chat", "--yolo"])
 86          self._simulate_cmd_chat_yolo_check(args)
 87          assert os.environ.get("HERMES_YOLO_MODE") == "1"
 88  
 89      def test_no_yolo_no_env(self):
 90          parser = _build_parser()
 91          args = parser.parse_args(["chat"])
 92          self._simulate_cmd_chat_yolo_check(args)
 93          assert os.environ.get("HERMES_YOLO_MODE") is None
 94  
 95  
 96  class TestAcceptHooksOnAgentSubparsers:
 97      """Verify --accept-hooks is accepted at every agent-subcommand
 98      position (before the subcommand, between group/subcommand, and
 99      after the leaf subcommand) for gateway/cron/mcp/acp.  Regression
100      against prior behaviour where the flag only worked on the root
101      parser and `chat`, so `hermes gateway run --accept-hooks` failed
102      with `unrecognized arguments`."""
103  
104      @pytest.mark.parametrize("argv", [
105          ["--accept-hooks", "gateway", "run", "--help"],
106          ["gateway", "--accept-hooks", "run", "--help"],
107          ["gateway", "run", "--accept-hooks", "--help"],
108          ["--accept-hooks", "cron", "tick", "--help"],
109          ["cron", "--accept-hooks", "tick", "--help"],
110          ["cron", "tick", "--accept-hooks", "--help"],
111          ["cron", "run", "--accept-hooks", "dummy-id", "--help"],
112          ["--accept-hooks", "mcp", "serve", "--help"],
113          ["mcp", "--accept-hooks", "serve", "--help"],
114          ["mcp", "serve", "--accept-hooks", "--help"],
115          ["acp", "--accept-hooks", "--help"],
116      ])
117      def test_accepted_at_every_position(self, argv):
118          """Invoking `hermes <argv>` must exit 0 (help) rather than
119          failing with `unrecognized arguments`."""
120          import subprocess
121          result = subprocess.run(
122              [sys.executable, "-m", "hermes_cli.main", *argv],
123              capture_output=True,
124              text=True,
125              timeout=15,
126          )
127          assert result.returncode == 0, (
128              f"argv={argv!r} returned {result.returncode}\n"
129              f"stdout: {result.stdout[:300]}\n"
130              f"stderr: {result.stderr[:300]}"
131          )
132          assert "unrecognized arguments" not in result.stderr