Files
hermes-agent/evals/cli_fallback_add_picker_error.py
Teknium 91b4d9fb67 test(fallback): keep the two persisted-config invariants; PTY harness for the picker error path
Trim the salvaged suite to the behaviour contracts: a picker exception or Ctrl+C leaves
config.yaml's model exactly as snapshotted (parametrized), and an absent active_provider
stays absent through snapshot+restore. The mock-dispatch tests (asserting calls into our own
helpers) are dropped. evals/cli_fallback_add_picker_error.py drives the real
`hermes fallback add` under a Linux PTY into an ordinary picker OSError and checks the
persisted model: stranded on base, restored after.
2026-09-06 05:41:46 -07:00

136 lines
6.2 KiB
Python

"""Drive the real ``hermes fallback add`` under a Linux PTY into an ordinary picker error.
Run from the checkout with the project Python. No provider requests are sent: the picker
target is a saved custom provider with ``discover_models: false``. The auth store is made
unreadable AFTER the provider menu renders (the pre-picker snapshot has already happened), so
the canonical picker writes the temporary primary route to config.yaml and then fails inside
``deactivate_provider`` with a plain ``PermissionError`` -- not ``SystemExit``.
The invariant under test: config.yaml ``model`` must equal the pre-picker primary afterwards.
"""
import argparse
import errno
import json
import os
from pathlib import Path
import pty
import re
import select
import signal
import struct
import subprocess
import sys
import tempfile
import termios
import time
import fcntl
PRIMARY = {"provider": "openrouter", "default": "primary/model-a",
"base_url": "https://openrouter.ai/api/v1", "api_mode": "chat_completions"}
CONFIG = (
"model:\n provider: openrouter\n default: primary/model-a\n"
" base_url: https://openrouter.ai/api/v1\n api_mode: chat_completions\n"
"custom_providers:\n - name: LocalLab\n base_url: http://127.0.0.1:9/v1\n"
" model: lab-model\n discover_models: false\n models:\n - lab-model\n"
"memory:\n provider: ''\n")
def _persisted_model(root: Path, env: dict) -> dict:
"""``config.yaml`` ``model`` section as the CLI itself reads it (owner module, same env)."""
out = subprocess.run(
[sys.executable, "-c", "import json; from hermes_cli.config import load_config; "
"print(json.dumps(load_config().get('model')))"],
cwd=root, env=env, capture_output=True, text=True, check=True)
return json.loads(out.stdout.strip().splitlines()[-1])
def run(root: Path, output: Path) -> dict:
with tempfile.TemporaryDirectory(prefix="hermes_test_fallback_") as home:
hh = Path(home) / ".hermes"
hh.mkdir()
(hh / "config.yaml").write_text(CONFIG, encoding="utf-8")
(hh / ".env").write_text("OPENROUTER_API_KEY=local-not-used\n", encoding="utf-8")
auth = hh / "auth.json"
auth.write_text(json.dumps({"version": 1, "providers": {}, "active_provider": "nous"}))
# A stub ``curses`` package forces every menu onto its numbered fallback so the PTY
# exchange is line-oriented (the curses UI is not what is under test here).
shim = Path(home) / "shim" / "curses"
shim.mkdir(parents=True)
(shim / "__init__.py").write_text("raise ImportError('curses disabled for PTY harness')\n")
env = {"PATH": os.environ["PATH"], "HOME": home, "HERMES_HOME": str(hh),
"PYTHONPATH": f"{shim.parent}{os.pathsep}{root}", "PYTHONUNBUFFERED": "1",
"TERM": "dumb", "LANG": "C.UTF-8"}
master, slave = pty.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 50, 120, 0, 0))
proc = subprocess.Popen([sys.executable, "-m", "hermes_cli.main", "fallback", "add"],
cwd=root, env=env, stdin=slave, stdout=slave, stderr=slave,
start_new_session=True)
os.close(slave)
data = bytearray()
def pump_until(predicate, timeout=60):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate(bytes(data)):
return True
if select.select([master], [], [], 0.1)[0]:
try:
chunk = os.read(master, 65536)
except OSError as exc:
if exc.errno == errno.EIO:
return predicate(bytes(data))
raise
if not chunk:
return predicate(bytes(data))
data.extend(chunk)
return predicate(bytes(data))
try:
assert pump_until(lambda b: b"Choice [default" in b), data[-2000:]
text = bytes(data).decode(errors="replace")
row = re.search(r"(\d+)\. LocalLab", text)
assert row, text[-3000:]
# Snapshot is done (menu is up); now make the auth store unreadable so the picker's
# own deactivate_provider() fails with an ordinary OSError after writing the model.
auth.chmod(0)
os.write(master, f"{row.group(1)}\r".encode())
offset = len(data)
assert pump_until(lambda b: b"Choice [" in b[offset:]), data[-2000:]
os.write(master, b"1\r")
exited = pump_until(lambda b: proc.poll() is not None, 90)
proc.wait(timeout=30)
auth.chmod(0o600)
model_after = _persisted_model(root, env)
text = bytes(data).decode(errors="replace")
return {"exited": exited, "returncode": proc.returncode,
"picker_error_surfaced": "PermissionError" in text,
"model_after": model_after, "primary_restored": model_after == PRIMARY,
"auth_active_provider": json.loads(auth.read_text()).get("active_provider"),
"restore_note": "Could not fully restore" in text,
"raw_path": str(output / "fallback-add-picker-error.pty")}
finally:
output.mkdir(parents=True, exist_ok=True)
(output / "fallback-add-picker-error.pty").write_bytes(data)
if proc.poll() is None:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait(timeout=30)
os.close(master)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--root", required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--expect", choices=("stranded", "restored"), required=True)
args = parser.parse_args()
result = run(Path(args.root).resolve(), args.output)
(args.output / "results.json").write_text(json.dumps(result, indent=2) + "\n")
print(json.dumps(result, indent=2))
assert result["exited"] and result["returncode"] != 0, result
assert result["picker_error_surfaced"], result
assert result["primary_restored"] == (args.expect == "restored"), result
if __name__ == "__main__":
main()