mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
`hermes -t web chat` silently dropped the toolset filter (and the same hold true for `-m`, `--provider`, `--tui`, `--dev` placed before `chat`). Reported in #28780 for `-t/--toolsets`; the others are sibling failures with the same root cause. Root cause: the chat subparser re-declared these flags with `default=None` (or `default=False` for store_true) on top of the matching top-level parser flags. When argparse dispatches into the subparser it shares the namespace via `dest`, so the subparser's default overwrites whatever the top-level parser parsed before the subcommand. `-s/--skills`, `-r/-c/-w`, `--yolo`, and `--pass-session-id` already use `default=argparse.SUPPRESS` for exactly this reason — the chat-subparser action becomes a no-op unless the user explicitly passes the flag after `chat`, and the parent value survives. Reproduction (origin/main, before fix): >>> parser.parse_known_args(["-t", "web", "chat"]).toolsets None >>> parser.parse_known_args(["chat", "-t", "web"]).toolsets 'web' After fix: >>> parser.parse_known_args(["-t", "web", "chat"]).toolsets 'web' >>> parser.parse_known_args(["chat", "-t", "web"]).toolsets 'web' Sibling flags fixed in the same commit because they share the exact same argparse pattern bug — verified via a new contract test that scans every chat-subparser action whose `dest` is also on the top-level parser and asserts `default is argparse.SUPPRESS`. The test fails on origin/main listing all five offenders and passes after this fix. Test additions in tests/hermes_cli/test_argparse_flag_propagation.py: - TestChatSubparserInheritedValueFlags exercising real `_parser` build (not the hand-rolled replica) so it catches future drift. - Parametrized before-chat / after-chat cases for `-t`, `--toolsets`, `-m`, `--model`, `--provider`. - Negative case: passing none of the flags leaves attrs at the top-level parser's `None` default (SUPPRESS does not remove existing attrs). - Combined case: all three value flags before `chat` simultaneously. - store_true cases for `--tui` / `--dev`. - Contract test asserting every shared-`dest` flag on chat uses SUPPRESS. Fixes #28780.
339 lines
14 KiB
Python
339 lines
14 KiB
Python
"""Tests for parent→subparser flag propagation.
|
|
|
|
When flags like --yolo, -w, -s exist on both the parent parser and the 'chat'
|
|
subparser, placing the flag BEFORE the subcommand (e.g. 'hermes --yolo chat')
|
|
must not silently drop the flag value.
|
|
|
|
Regression test for: argparse subparser default=False overwriting parent's
|
|
parsed True when the same argument is defined on both parsers.
|
|
|
|
Fix: chat subparser uses default=argparse.SUPPRESS for all duplicated flags,
|
|
so the subparser only sets the attribute when the user explicitly provides it.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
|
|
def _build_parser():
|
|
"""Build the hermes argument parser from the real code.
|
|
|
|
We import the real main() and extract the parser it builds.
|
|
Since main() is a large function that does much more than parse args,
|
|
we replicate just the parser structure here to avoid side effects.
|
|
"""
|
|
parser = argparse.ArgumentParser(prog="hermes")
|
|
parser.add_argument("--resume", "-r", metavar="SESSION", default=None)
|
|
parser.add_argument(
|
|
"--continue", "-c", dest="continue_last", nargs="?",
|
|
const=True, default=None, metavar="SESSION_NAME",
|
|
)
|
|
parser.add_argument("--worktree", "-w", action="store_true", default=False)
|
|
parser.add_argument("--skills", "-s", action="append", default=None)
|
|
parser.add_argument("--yolo", action="store_true", default=False)
|
|
parser.add_argument("--pass-session-id", action="store_true", default=False)
|
|
|
|
subparsers = parser.add_subparsers(dest="command")
|
|
chat = subparsers.add_parser("chat")
|
|
# These MUST use argparse.SUPPRESS to avoid overwriting parent values
|
|
chat.add_argument("--yolo", action="store_true",
|
|
default=argparse.SUPPRESS)
|
|
chat.add_argument("--worktree", "-w", action="store_true",
|
|
default=argparse.SUPPRESS)
|
|
chat.add_argument("--skills", "-s", action="append",
|
|
default=argparse.SUPPRESS)
|
|
chat.add_argument("--pass-session-id", action="store_true",
|
|
default=argparse.SUPPRESS)
|
|
chat.add_argument("--resume", "-r", metavar="SESSION_ID",
|
|
default=argparse.SUPPRESS)
|
|
chat.add_argument(
|
|
"--continue", "-c", dest="continue_last", nargs="?",
|
|
const=True, default=argparse.SUPPRESS, metavar="SESSION_NAME",
|
|
)
|
|
return parser
|
|
|
|
|
|
class TestChatVerboseArg:
|
|
"""Verify chat --verbose preserves config fallback when absent."""
|
|
|
|
def test_chat_without_verbose_leaves_attribute_unset(self):
|
|
from hermes_cli._parser import build_top_level_parser
|
|
|
|
parser, _subparsers, _chat_parser = build_top_level_parser()
|
|
args = parser.parse_args(["chat"])
|
|
|
|
assert not hasattr(args, "verbose")
|
|
|
|
def test_chat_verbose_sets_attribute_true(self):
|
|
from hermes_cli._parser import build_top_level_parser
|
|
|
|
parser, _subparsers, _chat_parser = build_top_level_parser()
|
|
args = parser.parse_args(["chat", "--verbose"])
|
|
|
|
assert args.verbose is True
|
|
|
|
def test_cmd_chat_forwards_none_when_verbose_is_absent(self, monkeypatch):
|
|
import types
|
|
import sys
|
|
|
|
import hermes_cli.main as main_mod
|
|
from hermes_cli._parser import build_top_level_parser
|
|
|
|
parser, _subparsers, chat_parser = build_top_level_parser()
|
|
chat_parser.set_defaults(func=main_mod.cmd_chat)
|
|
args = parser.parse_args(["chat"])
|
|
captured = {}
|
|
fake_cli = types.ModuleType("cli")
|
|
|
|
def fake_main(**kwargs):
|
|
captured.update(kwargs)
|
|
|
|
setattr(fake_cli, "main", fake_main)
|
|
fake_banner = types.ModuleType("hermes_cli.banner")
|
|
setattr(fake_banner, "prefetch_update_check", lambda: None)
|
|
fake_skills_sync = types.ModuleType("tools.skills_sync")
|
|
setattr(fake_skills_sync, "sync_skills", lambda quiet=True: None)
|
|
|
|
monkeypatch.setitem(sys.modules, "cli", fake_cli)
|
|
monkeypatch.setitem(sys.modules, "hermes_cli.banner", fake_banner)
|
|
monkeypatch.setitem(sys.modules, "tools.skills_sync", fake_skills_sync)
|
|
monkeypatch.setattr(main_mod, "_has_any_provider_configured", lambda: True)
|
|
monkeypatch.setattr(main_mod, "_pin_kanban_board_env", lambda: None)
|
|
|
|
main_mod.cmd_chat(args)
|
|
|
|
assert captured["quiet"] is False
|
|
assert "verbose" not in captured
|
|
|
|
|
|
class TestYoloEnvVar:
|
|
"""Verify --yolo sets HERMES_YOLO_MODE regardless of flag position.
|
|
|
|
This tests the actual cmd_chat logic pattern (getattr → os.environ).
|
|
"""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_env(self):
|
|
os.environ.pop("HERMES_YOLO_MODE", None)
|
|
yield
|
|
os.environ.pop("HERMES_YOLO_MODE", None)
|
|
|
|
def _simulate_cmd_chat_yolo_check(self, args):
|
|
"""Replicate the exact check from cmd_chat in main.py."""
|
|
if getattr(args, "yolo", False):
|
|
os.environ["HERMES_YOLO_MODE"] = "1"
|
|
|
|
def test_yolo_before_chat_sets_env(self):
|
|
parser = _build_parser()
|
|
args = parser.parse_args(["--yolo", "chat"])
|
|
self._simulate_cmd_chat_yolo_check(args)
|
|
assert os.environ.get("HERMES_YOLO_MODE") == "1"
|
|
|
|
def test_yolo_after_chat_sets_env(self):
|
|
parser = _build_parser()
|
|
args = parser.parse_args(["chat", "--yolo"])
|
|
self._simulate_cmd_chat_yolo_check(args)
|
|
assert os.environ.get("HERMES_YOLO_MODE") == "1"
|
|
|
|
def test_no_yolo_no_env(self):
|
|
parser = _build_parser()
|
|
args = parser.parse_args(["chat"])
|
|
self._simulate_cmd_chat_yolo_check(args)
|
|
assert os.environ.get("HERMES_YOLO_MODE") is None
|
|
|
|
|
|
class TestAcceptHooksOnAgentSubparsers:
|
|
"""Verify --accept-hooks is accepted at every agent-subcommand
|
|
position (before the subcommand, between group/subcommand, and
|
|
after the leaf subcommand) for gateway/cron/mcp/acp. Regression
|
|
against prior behaviour where the flag only worked on the root
|
|
parser and `chat`, so `hermes gateway run --accept-hooks` failed
|
|
with `unrecognized arguments`."""
|
|
|
|
ARGVS = [
|
|
["--accept-hooks", "gateway", "run", "--help"],
|
|
["gateway", "--accept-hooks", "run", "--help"],
|
|
["gateway", "run", "--accept-hooks", "--help"],
|
|
["--accept-hooks", "cron", "tick", "--help"],
|
|
["cron", "--accept-hooks", "tick", "--help"],
|
|
["cron", "tick", "--accept-hooks", "--help"],
|
|
["cron", "run", "--accept-hooks", "dummy-id", "--help"],
|
|
["--accept-hooks", "mcp", "serve", "--help"],
|
|
["mcp", "--accept-hooks", "serve", "--help"],
|
|
["mcp", "serve", "--accept-hooks", "--help"],
|
|
["acp", "--accept-hooks", "--help"],
|
|
]
|
|
|
|
# One driver subprocess parses ALL argvs: hermes_cli.main is a very heavy
|
|
# import (previously 11 separate `python -m hermes_cli.main` spawns with a
|
|
# 15s timeout each — a cold import on a loaded CI worker regularly blew
|
|
# that deadline, making this test flaky). Importing once and parsing 11
|
|
# times removes the repeated-import cost entirely; the generous timeout
|
|
# only trips on a genuine hang. `--help` exits via SystemExit(0), which
|
|
# the driver catches per argv.
|
|
_DRIVER = r"""
|
|
import io, json, sys
|
|
from contextlib import redirect_stdout, redirect_stderr
|
|
|
|
import hermes_cli.main as main_mod
|
|
|
|
argvs = json.loads(sys.argv[1])
|
|
results = []
|
|
for argv in argvs:
|
|
sys.argv = ["hermes", *argv]
|
|
out, err = io.StringIO(), io.StringIO()
|
|
code = 0
|
|
try:
|
|
with redirect_stdout(out), redirect_stderr(err):
|
|
main_mod.main()
|
|
except SystemExit as exc:
|
|
code = int(exc.code or 0)
|
|
except Exception as exc: # noqa: BLE001 - report, don't crash the driver
|
|
code = -1
|
|
err.write(repr(exc))
|
|
results.append({"argv": argv, "code": code, "stderr": err.getvalue()[:300]})
|
|
print(json.dumps(results))
|
|
"""
|
|
|
|
def test_accepted_at_every_position(self):
|
|
"""Every `hermes <argv>` must exit 0 (help) rather than failing
|
|
with `unrecognized arguments`."""
|
|
import json
|
|
import subprocess
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", self._DRIVER, json.dumps(self.ARGVS)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=180,
|
|
)
|
|
assert result.returncode == 0, (
|
|
f"driver failed rc={result.returncode}\n"
|
|
f"stdout: {result.stdout[:500]}\nstderr: {result.stderr[:500]}"
|
|
)
|
|
for entry in json.loads(result.stdout.strip().splitlines()[-1]):
|
|
assert entry["code"] == 0, (
|
|
f"argv={entry['argv']!r} returned {entry['code']}\n"
|
|
f"stderr: {entry['stderr']}"
|
|
)
|
|
assert "unrecognized arguments" not in entry["stderr"]
|
|
|
|
|
|
class TestChatSubparserInheritedValueFlags:
|
|
"""Verify -t/--toolsets, -m/--model and --provider survive parent→chat
|
|
subparser dispatch.
|
|
|
|
Regression test for #28780: `hermes -t web chat` silently dropped the
|
|
toolset because the chat subparser re-declared `-t/--toolsets` with
|
|
`default=None`, which clobbered the top-level parser's value during
|
|
subparser dispatch.
|
|
|
|
Uses the real `hermes_cli._parser.build_top_level_parser()` rather than
|
|
the hand-rolled replica above so this also fails if the production
|
|
parser drifts back to `default=None` on these flags.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def real_parser(self):
|
|
from hermes_cli._parser import build_top_level_parser
|
|
parser, _subparsers, _chat = build_top_level_parser()
|
|
return parser
|
|
|
|
@pytest.mark.parametrize("flag,attr,value", [
|
|
("-t", "toolsets", "web"),
|
|
("--toolsets", "toolsets", "web,terminal"),
|
|
("-m", "model", "anthropic/claude-sonnet-4"),
|
|
("--model", "model", "openai/gpt-4"),
|
|
("--provider", "provider", "openrouter"),
|
|
])
|
|
def test_flag_before_chat_is_preserved(self, real_parser, flag, attr, value):
|
|
args, _ = real_parser.parse_known_args([flag, value, "chat"])
|
|
assert getattr(args, attr, None) == value, (
|
|
f"`hermes {flag} {value} chat` lost the flag — got "
|
|
f"{getattr(args, attr, None)!r}, expected {value!r}"
|
|
)
|
|
|
|
@pytest.mark.parametrize("flag,attr,value", [
|
|
("-t", "toolsets", "web"),
|
|
("--toolsets", "toolsets", "web,terminal"),
|
|
("-m", "model", "anthropic/claude-sonnet-4"),
|
|
("--model", "model", "openai/gpt-4"),
|
|
("--provider", "provider", "openrouter"),
|
|
])
|
|
def test_flag_after_chat_still_works(self, real_parser, flag, attr, value):
|
|
args, _ = real_parser.parse_known_args(["chat", flag, value])
|
|
assert getattr(args, attr, None) == value
|
|
|
|
def test_no_flag_leaves_attrs_at_top_level_default(self, real_parser):
|
|
"""When the user passes none of the inherited flags, the top-level
|
|
parser's `default=None` still seeds the namespace — the SUPPRESS on
|
|
the subparser must not remove existing attributes."""
|
|
args, _ = real_parser.parse_known_args(["chat"])
|
|
assert getattr(args, "toolsets", "MISSING") is None
|
|
assert getattr(args, "model", "MISSING") is None
|
|
assert getattr(args, "provider", "MISSING") is None
|
|
|
|
def test_all_three_flags_before_chat(self, real_parser):
|
|
"""Issue #28780 reporter's case generalized: passing every inherited
|
|
value flag before `chat` must preserve all of them simultaneously."""
|
|
args, _ = real_parser.parse_known_args([
|
|
"-t", "web",
|
|
"-m", "anthropic/claude-sonnet-4",
|
|
"--provider", "openrouter",
|
|
"chat",
|
|
])
|
|
assert args.toolsets == "web"
|
|
assert args.model == "anthropic/claude-sonnet-4"
|
|
assert args.provider == "openrouter"
|
|
|
|
@pytest.mark.parametrize("flag,attr", [
|
|
("--tui", "tui"),
|
|
("--cli", "cli"),
|
|
("--dev", "tui_dev"),
|
|
])
|
|
def test_store_true_flag_before_chat_is_preserved(
|
|
self, real_parser, flag, attr,
|
|
):
|
|
"""`--tui` / `--cli` / `--dev` are store_true flags inherited by chat; the same
|
|
SUPPRESS contract applies. Without it, the subparser's `default=False`
|
|
would clobber the parent's `True` when used as `hermes --tui chat`."""
|
|
args, _ = real_parser.parse_known_args([flag, "chat"])
|
|
assert getattr(args, attr, None) is True, (
|
|
f"`hermes {flag} chat` lost the flag — got "
|
|
f"{getattr(args, attr, None)!r}, expected True"
|
|
)
|
|
|
|
def test_chat_subparser_inherited_value_flags_use_suppress(self):
|
|
"""Contract test for the underlying invariant.
|
|
|
|
Any chat-subparser flag whose `dest` also exists on the top-level
|
|
parser MUST declare `default=argparse.SUPPRESS`, otherwise the
|
|
subparser silently overwrites the top-level value with its own
|
|
default during dispatch. This is the structural class behind #28780.
|
|
"""
|
|
from hermes_cli._parser import build_top_level_parser
|
|
parser, _subparsers, chat_parser = build_top_level_parser()
|
|
|
|
top_level_dests = {
|
|
a.dest for a in parser._actions
|
|
if a.option_strings and a.dest != "help"
|
|
}
|
|
|
|
offenders = []
|
|
for action in chat_parser._actions:
|
|
if not action.option_strings or action.dest == "help":
|
|
continue
|
|
if action.dest not in top_level_dests:
|
|
continue
|
|
if action.default is not argparse.SUPPRESS:
|
|
offenders.append((action.option_strings, action.dest, action.default))
|
|
|
|
assert not offenders, (
|
|
"Chat subparser redeclares these top-level flags without "
|
|
"default=argparse.SUPPRESS; they will silently clobber the "
|
|
"top-level value when used as `hermes <flag> <value> chat`:\n "
|
|
+ "\n ".join(f"{opts} dest={dest} default={d!r}"
|
|
for opts, dest, d in offenders)
|
|
)
|