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.
136 lines
6.2 KiB
Python
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()
|