5ecc07986f
`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.
200 lines
6.3 KiB
Python
200 lines
6.3 KiB
Python
from __future__ import annotations
|
|
|
|
import subprocess
|
|
|
|
|
|
def _make_task(kb, *, assignee: str):
|
|
return kb.Task(
|
|
id="t_spawn_tools",
|
|
title="spawn tools",
|
|
body=None,
|
|
assignee=assignee,
|
|
status="running",
|
|
priority=0,
|
|
created_by="test",
|
|
created_at=1,
|
|
started_at=None,
|
|
completed_at=None,
|
|
workspace_kind="dir",
|
|
workspace_path=None,
|
|
claim_lock="lock",
|
|
claim_expires=None,
|
|
tenant=None,
|
|
current_run_id=7,
|
|
)
|
|
|
|
|
|
def test_default_spawn_pins_assignee_profile_cli_toolsets(monkeypatch, tmp_path):
|
|
"""Manual profile assignment should keep that profile's CLI tools.
|
|
|
|
Regression guard for dispatcher-spawned workers that boot with
|
|
HERMES_KANBAN_TASK: the worker must not collapse to only kanban lifecycle
|
|
tools when the assigned profile's top-level ``toolsets`` is the default
|
|
composite. The spawned CLI gets an explicit --toolsets pin resolved from
|
|
platform_toolsets.cli; model_tools appends task-scoped kanban tools later.
|
|
"""
|
|
root = tmp_path / ".hermes"
|
|
profile = root / "profiles" / "elias"
|
|
profile.mkdir(parents=True)
|
|
profile.joinpath("config.yaml").write_text(
|
|
"""
|
|
platform_toolsets:
|
|
cli:
|
|
- clarify
|
|
- code_execution
|
|
- delegation
|
|
- file
|
|
- memory
|
|
- session_search
|
|
- skills
|
|
- terminal
|
|
- web
|
|
toolsets:
|
|
- hermes-cli
|
|
agent:
|
|
disabled_toolsets: []
|
|
""".lstrip(),
|
|
encoding="utf-8",
|
|
)
|
|
root.joinpath("config.yaml").write_text("toolsets:\n - kanban\n", encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_HOME", str(root))
|
|
|
|
from hermes_cli import kanban_db as kb
|
|
|
|
monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: ["hermes"])
|
|
|
|
captured = {}
|
|
|
|
class FakeProc:
|
|
pid = 4242
|
|
|
|
def fake_popen(cmd, *args, **kwargs):
|
|
captured["cmd"] = list(cmd)
|
|
captured["env"] = dict(kwargs.get("env") or {})
|
|
captured["cwd"] = kwargs.get("cwd")
|
|
return FakeProc()
|
|
|
|
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
pid = kb._default_spawn(_make_task(kb, assignee="elias"), str(workspace))
|
|
|
|
assert pid == 4242
|
|
assert captured["env"]["HERMES_HOME"] == str(profile)
|
|
assert captured["env"]["HERMES_KANBAN_TASK"] == "t_spawn_tools"
|
|
assert "--toolsets" in captured["cmd"]
|
|
pinned = captured["cmd"][captured["cmd"].index("--toolsets") + 1].split(",")
|
|
for required in ("terminal", "web", "file", "skills", "code_execution", "delegation"):
|
|
assert required in pinned
|
|
|
|
|
|
def test_default_spawn_never_boots_the_tui(monkeypatch, tmp_path):
|
|
"""Workers are headless: an inherited HERMES_TUI=1 (or a TUI-default
|
|
config) must not send the quiet chat run into the Ink TUI, whose no-TTY
|
|
bail-out exits 0 without doing the task — every attempt then ends in
|
|
"protocol violation". The spawn pins --cli (highest-precedence interface
|
|
flag) and strips HERMES_TUI from the child env."""
|
|
root = tmp_path / ".hermes"
|
|
(root / "profiles" / "elias").mkdir(parents=True)
|
|
root.joinpath("config.yaml").write_text("display:\n interface: tui\n", encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_HOME", str(root))
|
|
monkeypatch.setenv("HERMES_TUI", "1")
|
|
|
|
from hermes_cli import kanban_db as kb
|
|
|
|
monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: ["hermes"])
|
|
|
|
captured = {}
|
|
|
|
class FakeProc:
|
|
pid = 4243
|
|
|
|
def fake_popen(cmd, *args, **kwargs):
|
|
captured["cmd"] = list(cmd)
|
|
captured["env"] = dict(kwargs.get("env") or {})
|
|
return FakeProc()
|
|
|
|
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
kb._default_spawn(_make_task(kb, assignee="elias"), str(workspace))
|
|
|
|
assert "--cli" in captured["cmd"]
|
|
assert "HERMES_TUI" not in captured["env"]
|
|
|
|
|
|
def test_default_spawn_model_override_survives_real_cli_parse(monkeypatch, tmp_path):
|
|
"""The dispatcher's pre-``chat`` model flag must reach ``args.model``.
|
|
|
|
This is an integration contract between Kanban's worker argv builder and
|
|
the real CLI parser. A parser default once erased the explicit override,
|
|
silently sending the worker to its profile default or fallback instead.
|
|
"""
|
|
root = tmp_path / ".hermes"
|
|
(root / "profiles" / "elias").mkdir(parents=True)
|
|
root.joinpath("config.yaml").write_text("{}\n", encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_HOME", str(root))
|
|
|
|
from hermes_cli import kanban_db as kb
|
|
from hermes_cli._parser import build_top_level_parser
|
|
|
|
monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: ["hermes"])
|
|
captured = {}
|
|
|
|
class FakeProc:
|
|
pid = 4244
|
|
|
|
def fake_popen(cmd, *args, **kwargs):
|
|
captured["cmd"] = list(cmd)
|
|
return FakeProc()
|
|
|
|
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
task = _make_task(kb, assignee="elias")
|
|
task.model_override = "gpt-5.6-sol"
|
|
kb._default_spawn(task, str(workspace))
|
|
|
|
parser, _subparsers, _chat_parser = build_top_level_parser()
|
|
# Profile selection is attached by the outer CLI bootstrap rather than
|
|
# build_top_level_parser(); remove that already-validated prefix and parse
|
|
# the worker flags/subcommand through the real shared parser.
|
|
assert captured["cmd"][1:3] == ["-p", "elias"]
|
|
args = parser.parse_args(captured["cmd"][3:])
|
|
|
|
assert args.command == "chat"
|
|
assert args.model == "gpt-5.6-sol"
|
|
assert args.query == "work kanban task t_spawn_tools"
|
|
|
|
|
|
def test_resolve_worker_cli_toolsets_uses_profile_home_not_parent_config(monkeypatch, tmp_path):
|
|
root = tmp_path / ".hermes"
|
|
profile = root / "profiles" / "elias"
|
|
profile.mkdir(parents=True)
|
|
root.joinpath("config.yaml").write_text("platform_toolsets:\n cli:\n - kanban\n", encoding="utf-8")
|
|
profile.joinpath("config.yaml").write_text(
|
|
"""
|
|
platform_toolsets:
|
|
cli:
|
|
- terminal
|
|
- web
|
|
toolsets:
|
|
- hermes-cli
|
|
""".lstrip(),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(root))
|
|
|
|
from hermes_cli import kanban_db as kb
|
|
|
|
resolved = kb._resolve_worker_cli_toolsets(str(profile))
|
|
|
|
assert resolved is not None
|
|
assert "terminal" in resolved
|
|
assert "web" in resolved
|
|
assert "kanban" in resolved # recovered worker lifecycle surface
|
|
assert resolved != ["kanban"]
|