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