Files
hermes-agent/tests/hermes_cli/test_resume_latest_and_in_dir.py
Totoro-qaq 5d6d5fb223 fix(cli): refresh TERMINAL_CWD when --in re-homes the session
`--in DIR` only chdir'd. Every cwd consumer (resolve_agent_cwd -> Codex
app-server thread cwd, the terminal tool, context-file discovery) prefers
TERMINAL_CWD over the process cwd, so a value inherited from a parent
Hermes surface, the shell or .env survived the chdir and the session kept
running in the old directory. The local backend was rescued by cli.py's
force-export at import time; docker/ssh backends and the TUI launch path,
which never imports cli.py, were not.

Refresh TERMINAL_CWD to the --in target when it is already set. An unset
variable stays unset so the backends keep deriving from the new process
cwd and no host path is pre-seeded into ssh/container backends.

Fixes #106220
2026-09-09 09:20:12 -07:00

280 lines
9.0 KiB
Python

"""Tests for `--resume latest` and `--in DIR` launch sugar.
`hermes --tui --resume latest --in ./dir` (and the classic-CLI equivalents)
resolve "latest" through the same workspace-scoped MRU lookup as `-c`, with
`--in` re-homing the process before any session resolution happens.
"""
from __future__ import annotations
from argparse import Namespace
import pytest
def _args(**overrides):
base = {
"cli": False,
"continue_last": None,
"in_dir": None,
"model": None,
"no_restore_cwd": False,
"provider": None,
"query": None,
"resume": None,
"safe_mode": False,
"toolsets": None,
"tui": True,
"tui_dev": False,
"worktree": False,
}
base.update(overrides)
return Namespace(**base)
@pytest.fixture
def main_mod(monkeypatch):
import hermes_cli.main as mod
monkeypatch.setattr(mod, "_has_any_provider_configured", lambda: True)
monkeypatch.setattr(mod, "_sync_bundled_skills_for_startup", lambda: False)
monkeypatch.setattr(mod, "_pin_kanban_board_env", lambda: None)
return mod
@pytest.fixture
def launched(main_mod, monkeypatch):
"""Capture the _launch_tui call instead of exec'ing Node."""
captured = {}
def fake_launch(resume_session_id=None, **kwargs):
captured["resume"] = resume_session_id
captured.update(kwargs)
raise SystemExit(0)
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
return captured
# ---------------------------------------------------------------------------
# argparse surface
# ---------------------------------------------------------------------------
def test_top_level_parser_accepts_in_and_resume_latest():
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, _chat = build_top_level_parser()
args = parser.parse_args(["--tui", "--resume", "latest", "--in", "./dir"])
assert args.tui is True
assert args.resume == "latest"
assert args.in_dir == "./dir"
def test_chat_subparser_accepts_in_flag():
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, _chat = build_top_level_parser()
args = parser.parse_args(["chat", "--in", "/tmp", "--resume", "latest"])
assert args.in_dir == "/tmp"
assert args.resume == "latest"
def test_top_level_in_value_not_mistaken_for_subcommand(monkeypatch):
# `hermes --in chat` — "chat" is the flag's value, not the subcommand.
import sys
import hermes_cli.main as mod
monkeypatch.setattr(sys, "argv", ["hermes", "--in", "chat", "--resume", "latest"])
assert mod._first_positional_argv() is None
# ---------------------------------------------------------------------------
# --resume latest resolution
# ---------------------------------------------------------------------------
def test_resume_latest_resolves_to_mru_session(main_mod, launched, monkeypatch):
monkeypatch.setattr(
main_mod, "_resolve_last_session", lambda source="cli": "20260807_120000_abc123"
)
# Keyword must NOT fall through to title resolution.
monkeypatch.setattr(
main_mod,
"_resolve_session_by_name_or_id",
lambda val: val if val != "latest" else pytest.fail("'latest' hit title resolution"),
)
with pytest.raises(SystemExit) as exc:
main_mod.cmd_chat(_args(resume="latest"))
assert exc.value.code == 0
assert launched["resume"] == "20260807_120000_abc123"
def test_resume_latest_tui_falls_back_to_cli_source(main_mod, launched, monkeypatch):
calls = []
def fake_resolve(source="cli"):
calls.append(source)
return "cli_session_1" if source == "cli" else None
monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve)
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda v: v)
with pytest.raises(SystemExit) as exc:
main_mod.cmd_chat(_args(resume="latest"))
assert exc.value.code == 0
assert calls == ["tui", "cli"]
assert launched["resume"] == "cli_session_1"
def test_resume_latest_is_case_insensitive(main_mod, launched, monkeypatch):
monkeypatch.setattr(main_mod, "_resolve_last_session", lambda source="cli": "sess_1")
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda v: v)
with pytest.raises(SystemExit):
main_mod.cmd_chat(_args(resume="Latest"))
assert launched["resume"] == "sess_1"
def test_resume_latest_no_sessions_exits_with_error(main_mod, monkeypatch, capsys):
monkeypatch.setattr(main_mod, "_resolve_last_session", lambda source="cli": None)
with pytest.raises(SystemExit) as exc:
main_mod.cmd_chat(_args(resume="latest"))
assert exc.value.code == 1
out = capsys.readouterr().out
assert "No previous TUI session found" in out
def test_resume_real_id_untouched_by_latest_keyword(main_mod, launched, monkeypatch):
monkeypatch.setattr(
main_mod,
"_resolve_last_session",
lambda source="cli": pytest.fail("MRU lookup must not run for explicit IDs"),
)
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda v: v)
with pytest.raises(SystemExit):
main_mod.cmd_chat(_args(resume="20260807_120000_abc123"))
assert launched["resume"] == "20260807_120000_abc123"
# ---------------------------------------------------------------------------
# --in DIR
# ---------------------------------------------------------------------------
def test_in_dir_chdirs_before_session_resolution(main_mod, launched, monkeypatch, tmp_path):
import os
target = tmp_path / "projdir"
target.mkdir()
start = os.getcwd()
seen_cwd = {}
def fake_resolve(source="cli"):
seen_cwd["at_resolve"] = os.getcwd()
return "sess_scoped"
monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve)
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda v: v)
try:
with pytest.raises(SystemExit):
main_mod.cmd_chat(_args(resume="latest", in_dir=str(target)))
finally:
os.chdir(start)
assert seen_cwd["at_resolve"] == str(target.resolve())
assert launched["resume"] == "sess_scoped"
def test_in_dir_sets_no_restore_cwd(main_mod, launched, monkeypatch, tmp_path):
import os
target = tmp_path / "pin-here"
target.mkdir()
start = os.getcwd()
args = _args(resume=None, in_dir=str(target))
try:
with pytest.raises(SystemExit):
main_mod.cmd_chat(args)
finally:
os.chdir(start)
assert args.no_restore_cwd is True
def test_in_dir_missing_directory_exits(main_mod, monkeypatch, tmp_path, capsys):
with pytest.raises(SystemExit) as exc:
main_mod.cmd_chat(_args(in_dir=str(tmp_path / "nope")))
assert exc.value.code == 1
assert "--in directory not found" in capsys.readouterr().out
def test_in_dir_expands_user_home(main_mod, launched, monkeypatch, tmp_path):
import os
home = tmp_path / "home"
(home / "proj").mkdir(parents=True)
monkeypatch.setenv("HOME", str(home))
start = os.getcwd()
try:
with pytest.raises(SystemExit):
main_mod.cmd_chat(_args(in_dir="~/proj"))
assert os.getcwd() == str((home / "proj").resolve())
finally:
os.chdir(start)
# ---------------------------------------------------------------------------
# --in must win over an inherited TERMINAL_CWD (#106220)
# ---------------------------------------------------------------------------
def test_in_dir_replaces_inherited_terminal_cwd(main_mod, monkeypatch, tmp_path):
"""A parent Hermes surface, the shell or .env can export TERMINAL_CWD before
the CLI starts. Every cwd consumer prefers that variable over the process
cwd, so a bare chdir left the Codex app-server thread, the terminal tool and
context-file discovery in the inherited directory."""
import os
from pathlib import Path
from agent.runtime_cwd import resolve_agent_cwd
inherited = tmp_path / "inherited"
target = tmp_path / "target"
inherited.mkdir()
target.mkdir()
monkeypatch.chdir(inherited)
monkeypatch.setenv("TERMINAL_CWD", str(inherited))
args = _args(in_dir=str(target))
main_mod._apply_in_dir(args)
assert Path.cwd().resolve() == target.resolve()
assert Path(os.environ["TERMINAL_CWD"]).resolve() == target.resolve()
assert resolve_agent_cwd().resolve() == target.resolve()
assert args.no_restore_cwd is True
def test_in_dir_leaves_unset_terminal_cwd_unset(main_mod, monkeypatch, tmp_path):
"""Without an inherited value the backends already derive from the process
cwd (local exports os.getcwd() at cli import, docker mounts it, the TUI
child inherits the chdir). Pre-seeding a host path here would leak it into
ssh/container backends that must keep their own default."""
import os
target = tmp_path / "target"
target.mkdir()
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("TERMINAL_CWD", raising=False)
main_mod._apply_in_dir(_args(in_dir=str(target)))
assert "TERMINAL_CWD" not in os.environ