`_suppress_mouse_residue_early()` runs at import time, before `_apply_profile_override()` sets HERMES_HOME, so the first read of `display.interface` always saw the default home. The result was memoised in a cache keyed on nothing, so every later caller — the Termux fast paths and the TUI launch decision, which all run after the profile is applied — was pinned to the default home's interface for the whole run. `hermes -p coder` with `display.interface: tui` in that profile booted the classic REPL, and the mirror case booted the TUI for a profile that asked for cli. Key the cache on the config path it read. The hot path still parses the YAML once per home, and the first call after the process is re-homed re-reads the file it should have read. Fixes #116902
196 lines
7.7 KiB
Python
196 lines
7.7 KiB
Python
"""Tests for the configurable default interface (cli vs tui).
|
|
|
|
`hermes` launches the classic prompt_toolkit REPL by default, but users can
|
|
flip ``display.interface: tui`` in config.yaml to make the modern Ink TUI the
|
|
default for bare ``hermes`` / ``hermes chat``. Explicit flags always win:
|
|
|
|
--cli forces the classic REPL (highest precedence)
|
|
--tui forces the TUI
|
|
(no TTY) forces the classic REPL — ambient prefs don't apply
|
|
HERMES_TUI=1 the env default
|
|
display.interface the configured default
|
|
(unset) classic REPL
|
|
|
|
The no-TTY gate exists because ambient TUI preferences must never hijack
|
|
non-interactive invocations: kanban workers / cron / pipelines run
|
|
``hermes … chat -q`` on a pipe, and the TUI's no-TTY bail-out exits 0
|
|
without doing the work (a kanban worker then dies with "protocol
|
|
violation" on every attempt).
|
|
|
|
These tests pin that precedence at every layer that makes the decision:
|
|
|
|
* ``_resolve_use_tui(args)`` — the canonical args-aware resolver used by
|
|
``cmd_chat`` and the Termux fast-TUI path.
|
|
* ``_wants_tui_early(argv)`` — the dependency-free early resolver used by
|
|
mouse-residue suppression and the Termux fast paths, before argparse and
|
|
``hermes_cli.config`` are importable.
|
|
* the argument parser — both ``--cli`` and ``--tui`` parse at the top
|
|
level and under the ``chat`` subcommand and are relaunch-inherited.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import main as m
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_early_cache(monkeypatch):
|
|
# The early resolver memoizes the config read; clear it so each test sees
|
|
# a fresh value, and make sure no stray HERMES_TUI leaks in.
|
|
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
|
|
monkeypatch.delenv("HERMES_TUI", raising=False)
|
|
yield
|
|
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
|
|
|
|
|
|
def _args(**kw):
|
|
kw.setdefault("cli", False)
|
|
kw.setdefault("tui", False)
|
|
return SimpleNamespace(**kw)
|
|
|
|
|
|
def _fake_tty(monkeypatch, interactive: bool):
|
|
"""Pin stdin/stdout TTY-ness — pytest's capture is never a real TTY."""
|
|
import sys as _sys
|
|
|
|
monkeypatch.setattr(_sys.stdin, "isatty", lambda: interactive, raising=False)
|
|
monkeypatch.setattr(_sys.stdout, "isatty", lambda: interactive, raising=False)
|
|
|
|
|
|
def _patch_config(monkeypatch, interface):
|
|
import hermes_cli.config as cfg
|
|
|
|
monkeypatch.setattr(
|
|
cfg, "load_config", lambda: {"display": {"interface": interface}}
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _resolve_use_tui — args-aware resolver
|
|
# ---------------------------------------------------------------------------
|
|
class TestResolveUseTui:
|
|
def test_cli_flag_beats_config_tui(self, monkeypatch):
|
|
_patch_config(monkeypatch, "tui")
|
|
assert m._resolve_use_tui(_args(cli=True)) is False
|
|
|
|
|
|
def test_load_config_failure_falls_back_to_cli(self, monkeypatch):
|
|
import hermes_cli.config as cfg
|
|
|
|
def boom():
|
|
raise RuntimeError("config unreadable")
|
|
|
|
monkeypatch.setattr(cfg, "load_config", boom)
|
|
_fake_tty(monkeypatch, True)
|
|
assert m._resolve_use_tui(_args()) is False
|
|
|
|
# ── the no-TTY gate: ambient prefs never hijack non-interactive runs ────
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _wants_tui_early — dependency-free early resolver
|
|
# ---------------------------------------------------------------------------
|
|
class TestWantsTuiEarly:
|
|
@pytest.fixture
|
|
def home_with_interface(self, tmp_path, monkeypatch):
|
|
def _make(interface):
|
|
(tmp_path / "config.yaml").write_text(
|
|
f"display:\n interface: {interface}\n"
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
|
|
|
|
return _make
|
|
|
|
|
|
def test_config_cli_bare_argv(self, home_with_interface):
|
|
home_with_interface("cli")
|
|
assert m._wants_tui_early([]) is False
|
|
|
|
def test_missing_config_defaults_to_cli(self, tmp_path, monkeypatch):
|
|
# HERMES_HOME points at an empty dir — no config.yaml.
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
|
|
assert m._wants_tui_early([]) is False
|
|
|
|
def test_unreadable_config_defaults_to_cli(self, tmp_path, monkeypatch):
|
|
# Garbage YAML must not crash the hot path; falls back to cli.
|
|
(tmp_path / "config.yaml").write_text("this: : : not valid yaml\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
|
|
assert m._wants_tui_early([]) is False
|
|
|
|
# REGRESSION (#116902): mouse-residue suppression reads the interface
|
|
# before `_apply_profile_override()` sets HERMES_HOME, so a cache that
|
|
# ignored the home answered every later caller with the DEFAULT home's
|
|
# interface — `hermes -p <name>` booted the wrong one.
|
|
def test_reread_after_the_profile_rehomes_the_process(self, tmp_path, monkeypatch):
|
|
default_home = tmp_path / "default"
|
|
profile_home = tmp_path / "profiles" / "coder"
|
|
for home, interface in ((default_home, "cli"), (profile_home, "tui")):
|
|
home.mkdir(parents=True)
|
|
(home / "config.yaml").write_text(
|
|
f"display:\n interface: {interface}\n"
|
|
)
|
|
|
|
# The import-time read, on the home the process starts in.
|
|
_fake_tty(monkeypatch, True)
|
|
monkeypatch.setenv("HERMES_HOME", str(default_home))
|
|
m._suppress_mouse_residue_early()
|
|
assert m._config_default_interface_early() == "cli"
|
|
|
|
# What `-p coder` does, after that read already happened.
|
|
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
|
assert m._config_default_interface_early() == "tui"
|
|
assert m._wants_tui_early([]) is True
|
|
|
|
def test_same_home_is_read_only_once(self, tmp_path, monkeypatch):
|
|
(tmp_path / "config.yaml").write_text("display:\n interface: tui\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
assert m._config_default_interface_early() == "tui"
|
|
|
|
# The cache still spares the hot path a second YAML parse.
|
|
(tmp_path / "config.yaml").write_text("display:\n interface: cli\n")
|
|
assert m._config_default_interface_early() == "tui"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# argument parser — flags exist at both levels and are relaunch-inherited
|
|
# ---------------------------------------------------------------------------
|
|
class TestParserFlags:
|
|
def _parser(self):
|
|
from hermes_cli._parser import build_top_level_parser
|
|
|
|
parser, _subparsers, _chat = build_top_level_parser()
|
|
return parser
|
|
|
|
def test_top_level_cli_flag(self):
|
|
args = self._parser().parse_args(["--cli"])
|
|
assert args.cli is True and args.tui is False
|
|
|
|
|
|
def test_chat_subcommand_tui_flag(self):
|
|
args = self._parser().parse_args(["chat", "--tui"])
|
|
assert args.tui is True
|
|
|
|
def test_cli_and_tui_are_relaunch_inherited(self):
|
|
from hermes_cli.relaunch import _INHERITED_FLAGS_TABLE
|
|
|
|
inherited = {flag for flag, _takes_value in _INHERITED_FLAGS_TABLE}
|
|
assert "--cli" in inherited
|
|
assert "--tui" in inherited
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# config default — shipped default preserves classic behavior
|
|
# ---------------------------------------------------------------------------
|
|
def test_default_config_interface_is_cli():
|
|
from hermes_cli.config import DEFAULT_CONFIG
|
|
|
|
assert DEFAULT_CONFIG["display"]["interface"] == "cli"
|