Files
pheby-hermes-platform-adapter/tests/test_pheby.py
T

1246 lines
53 KiB
Python

"""Pheby plugin test suite.
Run with the Hermes venv's pytest from the repo root:
/opt/hermes/.venv/bin/python -m pytest tests/ -o 'addopts=' -q
All tests use fakes for the Hermes gateway — no LLM calls, no network beyond
localhost, no real HERMES_HOME writes (HERMES_HOME is redirected to a tmp dir
by ``conftest.py``).
"""
from __future__ import annotations
import asyncio
import datetime as dt
import json
import threading
import time
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Dict, List, Optional
import pytest
# Make the plugin package importable regardless of install layout.
import sys
_PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugin"
if str(_PLUGIN_DIR) not in sys.path:
sys.path.insert(0, str(_PLUGIN_DIR))
from pheby import protocol as proto # noqa: E402
from pheby.attachments import AttachmentStore, constant_time_equals # noqa: E402
from pheby.adapter import PhebyAdapter # noqa: E402
from pheby.config import load_config # noqa: E402
from pheby.conversations import ConversationRouter # noqa: E402
from pheby.server import PhebyServer # noqa: E402
# ═══════════════════════════════════════════════════════════════════════════
# Fakes
# ═══════════════════════════════════════════════════════════════════════════
class FakeWS:
"""Minimal WebSocketResponse stand-in for server-loop tests."""
def __init__(self):
self.sent: List[str] = []
self.inbox: "asyncio.Queue[str]" = asyncio.Queue()
self.closed = False
self.close_code: Optional[int] = None
async def send_str(self, data: str) -> None:
if self.closed:
raise ConnectionError("closed")
self.sent.append(data)
async def receive(self, timeout: Optional[float] = None):
class _Msg:
def __init__(self, data: str):
self.type = "text"
self.data = data
try:
return _Msg(await asyncio.wait_for(self.inbox.get(),
timeout=timeout))
except asyncio.TimeoutError:
raise
async def close(self, code: Optional[int] = None, message=None):
self.closed = True
self.close_code = code
def events(self) -> List[Dict[str, Any]]:
out = []
for raw in self.sent:
try:
out.append(json.loads(raw))
except json.JSONDecodeError:
pass
return out
class FakeClientConnection:
"""Wraps FakeWS with the ClientConnection interface the server expects."""
def __init__(self):
self.ws = FakeWS()
self.conn_id = "test-conn"
self.authenticated = False
self.protocol_version = None
self.connected_at = time.time()
self.closed = False
self._send_lock = asyncio.Lock()
async def send_json(self, payload: Dict[str, Any]) -> bool:
if self.closed:
return False
try:
async with self._send_lock:
await self.ws.send_str(proto.encode_message(payload))
return True
except (ConnectionError, RuntimeError, asyncio.CancelledError):
self.closed = True
return False
class FakeAdapter:
"""Adapter stand-in: enough surface for bridge tests."""
def __init__(self):
self.platform = type("P", (), {"value": "pheby"})()
self.gateway_runner = None
self._active_sessions: Dict[str, Any] = {}
self.handled: List[Any] = []
def build_source(self, **kwargs):
from gateway.session import SessionSource # real Hermes type
return SessionSource(
platform=self.platform, chat_id=kwargs.get("chat_id", "x"),
chat_type="dm", user_id="pheby-client")
async def handle_message(self, event) -> None:
self.handled.append(event)
async def interrupt_session_activity(self, session_key, chat_id,
metadata=None):
self.interrupted = (session_key, chat_id)
class FakeRunner:
"""Gateway runner stand-in for session-key + interrupt tests."""
def __init__(self):
self.session_store = None
self._session_db = None
self._running_agents: Dict[str, Any] = {}
self.generations: Dict[str, int] = {}
def _generate_session_key(self, source):
return f"agent:main:pheby:dm:{source.chat_id}"
def _invalidate_session_run_generation(self, session_key, reason=""):
self.generations[session_key] = \
self.generations.get(session_key, 0) + 1
class FakeAgent:
def __init__(self):
self.interrupts: List[str] = []
def interrupt(self, message=None):
self.interrupts.append(message or "")
def make_server(tmp_path: Path, **overrides) -> PhebyServer:
cfg = load_config({
"secret": "test-secret-abc123",
"port": overrides.pop("port", 0), # 0 unused in handler tests
**overrides,
})
cfg.secret = overrides.get("secret", cfg.secret or "test-secret-abc123")
root = Path(tmp_path) / "attachments"
server = PhebyServer(cfg, adapter=FakeAdapter())
server.store = AttachmentStore(root=root, retention_days=7)
hermes_bridge_set(server)
return server
def hermes_bridge_set(server: PhebyServer) -> None:
from pheby import hermes_bridge
hermes_bridge.set_server(server)
if server.adapter is not None:
hermes_bridge.set_adapter(server.adapter)
# ═══════════════════════════════════════════════════════════════════════════
# Protocol serialization
# ═══════════════════════════════════════════════════════════════════════════
class TestProtocol:
def test_roundtrip(self):
msg = {"type": proto.C_CHAT_SEND, "conversation_id": "abc",
"text": "héllo 🐱", "request_id": "r1"}
data = proto.encode_message(msg)
parsed, err = proto.decode_message(data)
assert err is None and parsed == msg
def test_invalid_json_rejected(self):
for bad in ("{not json", "[]", '"str"', "42", '{"no_type": 1}', ""):
parsed, err = proto.decode_message(bad)
assert parsed is None and err == proto.ERR_INVALID_JSON
def test_error_event_shape(self):
ev = proto.error_event(proto.ERR_BAD_REQUEST, "boom", request_id="r9")
assert ev["type"] == proto.S_ERROR
assert ev["error"]["code"] == proto.ERR_BAD_REQUEST
assert ev["request_id"] == "r9"
def test_attachment_id_format(self):
aid = proto.new_id()
assert len(aid) == 32 and proto.is_valid_attachment_id(aid)
assert not proto.is_valid_attachment_id("../etc/passwd")
assert not proto.is_valid_attachment_id("")
# ═══════════════════════════════════════════════════════════════════════════
# Authentication
# ═══════════════════════════════════════════════════════════════════════════
class TestAuth:
@pytest.mark.asyncio
async def test_hello_success(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.ws.inbox.put_nowait(proto.encode_message({
"type": proto.C_HELLO, "secret": "test-secret-abc123",
"protocol_version": proto.PROTOCOL_VERSION}))
ok = await server._authenticate(client, "peer1")
assert ok and client.authenticated
@pytest.mark.asyncio
async def test_hello_wrong_secret(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
bad_hello = proto.encode_message(
{"type": proto.C_HELLO, "secret": "wrong"})
client.ws.inbox.put_nowait(bad_hello)
ok = await server._authenticate(client, "peer2")
assert not ok and not client.authenticated
# 5 failures → lockout (each attempt needs its own hello frame)
for _ in range(proto.AUTH_FAILURE_THRESHOLD - 1):
client.ws.inbox.put_nowait(bad_hello)
await server._authenticate(client, "peer2")
assert server._is_locked_out("peer2")
@pytest.mark.asyncio
async def test_first_message_not_hello(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.ws.inbox.put_nowait(proto.encode_message(
{"type": proto.C_PING}))
ok = await server._authenticate(client, "peer3")
assert not ok
@pytest.mark.asyncio
async def test_version_mismatch_refused(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.ws.inbox.put_nowait(proto.encode_message({
"type": proto.C_HELLO, "secret": "test-secret-abc123",
"protocol_version": 99}))
ok = await server._authenticate(client, "peer4")
assert not ok and not client.authenticated
@pytest.mark.asyncio
async def test_malformed_version_is_structured_error(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.ws.inbox.put_nowait(proto.encode_message({
"type": proto.C_HELLO, "secret": "test-secret-abc123",
"protocol_version": {"not": "an integer"}}))
ok = await server._authenticate(client, "peer5")
assert not ok and not client.authenticated
error = client.ws.events()[-1]
assert error["type"] == proto.S_ERROR
assert error["error"]["code"] == proto.ERR_VERSION_MISMATCH
def test_constant_time_equals(self):
assert constant_time_equals("abc", "abc")
assert not constant_time_equals("abc", "abd")
assert not constant_time_equals("abc", "abcd")
assert not constant_time_equals("", "x")
# ═══════════════════════════════════════════════════════════════════════════
# Conversation operations
# ═══════════════════════════════════════════════════════════════════════════
class TestConversations:
@pytest.mark.asyncio
async def test_create_list_rename_delete(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
await server._handle_conversation_create(client, {
"type": proto.C_CONVERSATION_CREATE, "name": "Project X"}, "r1")
created = client.ws.events()[-1]
cid = created["conversation_id"]
assert created["type"] == proto.S_CONVERSATION_CREATED
assert created["name"] == "Project X"
# list includes it
await server._handle_conversation_list(client, {
"type": proto.C_CONVERSATION_LIST}, "r2")
snap = client.ws.events()[-1]
assert any(c["conversation_id"] == cid for c in snap["conversations"])
# rename
await server._handle_conversation_rename(client, {
"type": proto.C_CONVERSATION_RENAME,
"conversation_id": cid, "name": "Renamed"}, "r3")
renamed = client.ws.events()[-1]
assert renamed["type"] == proto.S_CONVERSATION_RENAMED
assert renamed["name"] == "Renamed"
# open (empty history, conversation exists in router)
await server._handle_conversation_open(client, {
"type": proto.C_CONVERSATION_OPEN,
"conversation_id": cid}, "r4")
hist = client.ws.events()[-1]
assert hist["type"] == proto.S_CONVERSATION_HISTORY
assert hist["messages"] == []
# delete
await server._handle_conversation_delete(client, {
"type": proto.C_CONVERSATION_DELETE,
"conversation_id": cid}, "r5")
deleted = client.ws.events()[-1]
assert deleted["type"] == proto.S_CONVERSATION_DELETED
# open after delete → not found
await server._handle_conversation_open(client, {
"type": proto.C_CONVERSATION_OPEN,
"conversation_id": cid}, "r6")
err = client.ws.events()[-1]
assert err["type"] == proto.S_ERROR
assert err["error"]["code"] == proto.ERR_CONVERSATION_NOT_FOUND
@pytest.mark.asyncio
async def test_invalid_id_rejected(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
await server._handle_conversation_open(client, {
"type": proto.C_CONVERSATION_OPEN,
"conversation_id": "../../etc"}, "r1")
ev = client.ws.events()[-1]
assert ev["error"]["code"] == proto.ERR_BAD_REQUEST
@pytest.mark.asyncio
async def test_ids_survive_router_reload(self, tmp_path):
server = make_server(tmp_path)
cid = await server.router.new_conversation("Persisted")
# New router instance (simulates restart) sees the same ID.
router2 = ConversationRouter()
assert await router2.get_name(cid) == "Persisted"
# ═══════════════════════════════════════════════════════════════════════════
# Attachments
# ═══════════════════════════════════════════════════════════════════════════
class TestAttachments:
def test_adapter_anchors_deliverable_to_active_assistant_draft(self):
adapter = object.__new__(PhebyAdapter)
adapter._drafts = {"conv": {"message_id": "draft-run-1", "text": ""}}
assert adapter._active_assistant_message_id("conv") == "draft-run-1"
assert adapter._active_assistant_message_id("unknown") is None
@pytest.mark.asyncio
async def test_register_describe_download_path(self, tmp_path):
src = Path(tmp_path) / "report.pdf"
src.write_bytes(b"%PDF-1.4 fake")
store = AttachmentStore(root=Path(tmp_path) / "att", retention_days=7)
desc = await store.register_file(str(src), conversation_id="conv1")
assert desc is not None
assert desc["filename"] == "report.pdf"
assert desc["mime_type"] == "application/pdf"
assert desc["inline_image"] is False
assert desc["download_path"].startswith("/attachments/")
# Blob resolves only via the registered ID
blob = store.resolve_blob(desc["attachment_id"])
assert blob is not None and blob.read_bytes() == b"%PDF-1.4 fake"
@pytest.mark.asyncio
async def test_image_detection(self, tmp_path):
src = Path(tmp_path) / "pic.png"
src.write_bytes(b"\x89PNG fake")
store = AttachmentStore(root=Path(tmp_path) / "att", retention_days=7)
desc = await store.register_file(str(src), conversation_id="c")
assert desc["kind"] == "image" and desc["inline_image"] is True
@pytest.mark.asyncio
async def test_unknown_and_traversal_ids(self, tmp_path):
store = AttachmentStore(root=Path(tmp_path) / "att", retention_days=7)
assert store.resolve_blob("f" * 32) is None
assert store.resolve_blob("../../etc/passwd") is None
assert store.resolve_blob("../" + "a" * 32) is None
assert store.resolve_blob("") is None
@pytest.mark.asyncio
async def test_seven_day_expiry(self, tmp_path):
src = Path(tmp_path) / "old.txt"
src.write_text("expired soon")
store = AttachmentStore(root=Path(tmp_path) / "att", retention_days=7)
desc = await store.register_file(str(src), conversation_id="c")
aid = desc["attachment_id"]
assert store.resolve_blob(aid) is not None
# Force age beyond retention.
store._meta[aid]["created_epoch"] = time.time() - 8 * 86400
assert store.resolve_blob(aid) is None # expired → unavailable
removed = await store.cleanup_expired()
assert removed == 1
# Blob actually gone from disk; metadata index updated.
assert store._meta.get(aid) is None
@pytest.mark.asyncio
async def test_cleanup_never_touches_unrelated_files(self, tmp_path):
root = Path(tmp_path) / "att"
store = AttachmentStore(root=root, retention_days=7)
stranger = root / "blobs" / "zz" / "unrelated.txt"
stranger.parent.mkdir(parents=True)
stranger.write_text("keep me")
await store.cleanup_expired()
assert stranger.exists() # untouched
@pytest.mark.asyncio
async def test_metadata_survives_restart(self, tmp_path):
src = Path(tmp_path) / "doc.md"
src.write_text("# hi")
root = Path(tmp_path) / "att"
store1 = AttachmentStore(root=root, retention_days=7)
desc = await store1.register_file(str(src), conversation_id="c")
store2 = AttachmentStore(root=root, retention_days=7)
store2.hydrate_legacy_meta() # no-op for index-file storage
assert store2.resolve_blob(desc["attachment_id"]) is not None
@pytest.mark.asyncio
async def test_missing_source_file(self, tmp_path):
store = AttachmentStore(root=Path(tmp_path) / "att", retention_days=7)
desc = await store.register_file(
str(Path(tmp_path) / "nope.bin"), conversation_id="c")
assert desc is None
# ═══════════════════════════════════════════════════════════════════════════
# Tool events / approvals / clarifications / cancellation (bridge)
# ═══════════════════════════════════════════════════════════════════════════
class TestBridge:
@pytest.mark.asyncio
async def test_history_converts_numeric_hermes_timestamps_to_iso(self, monkeypatch):
from pheby import hermes_bridge as hb
class Store:
def peek_session_id(self, _key):
return "session-1"
class DB:
def get_messages_as_conversation(self, _session_id, include_row_ids=False):
assert include_row_ids is True
return [
{"_row_id": 10, "role": "user", "content": "hello", "timestamp": 1_789_000_000.25},
{"_row_id": 11, "role": "assistant", "content": "reply", "timestamp": 1_789_000_001.5},
]
monkeypatch.setattr(hb, "_session_store", lambda: Store())
monkeypatch.setattr(hb, "_session_db", lambda: DB())
history, found = await hb.conversation_history("conv", 20)
assert found is True
assert [message["message_id"] for message in history] == ["m10", "m11"]
assert history[0]["ts"] == "2026-09-10T00:26:40.250000+00:00"
assert history[1]["ts"] == "2026-09-10T00:26:41.500000+00:00"
def test_iso_parses_by_instant_for_naive_local_datetimes(self):
# gateway session-store timestamps are naive LOCAL datetimes
# (gateway.session_lifecycle._now). last_active must come back with a
# UTC offset, or the Kotlin client's Instant.parse() fails and the
# chat list loses its recency ordering.
from pheby import hermes_bridge as hb
naive = dt.datetime(2026, 9, 10, 5, 41, 48, 401094) # naive local
got = hb._iso(naive)
assert got is not None
assert got.endswith("+00:00")
# Java-style Instant.parse equivalent: parse with offset required.
parsed = dt.datetime.fromisoformat(got)
assert parsed.tzinfo is not None
# Aware datetimes pass through normalized to UTC unchanged.
aware = dt.datetime(2026, 9, 10, 12, 0, tzinfo=dt.timezone(dt.timedelta(hours=-5)))
assert hb._iso(aware) == "2026-09-10T17:00:00+00:00"
@pytest.mark.asyncio
async def test_tool_start_event_is_structured_not_text(self, tmp_path,
monkeypatch):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
server._clients["t"] = client
# Use the real PhebyAdapter for the tool-event path (FakeAdapter has
# no format_tool_event; the real one is what we're testing).
from pheby.adapter import PhebyAdapter
from gateway.config import PlatformConfig
real = PhebyAdapter(PlatformConfig(
enabled=True, extra={"secret": "test-secret-abc123"}))
real._pcfg = server.config
real._server = server
real._loop = asyncio.get_running_loop()
real._active_sessions = {"agent:main:pheby:dm:conv1": asyncio.Event()}
monkeypatch.setattr(real, "_conversation_for_session_id",
lambda _sid: "conv1")
from gateway.stream_events import ToolCallChunk
marker = real.format_tool_event(
ToolCallChunk(tool_name="web_search", preview="cats",
args={"query": "cats"}, index=0),
mode="all")
assert marker is None # never rendered as chat text
# The display event is intentionally eaten. The authoritative hook
# carries the real Hermes session and tool-call IDs.
real.on_pre_tool_call(
session_id="session-1", tool_name="web_search",
tool_call_id="call-1", args={"query": "cats"})
await asyncio.sleep(0)
events = client.ws.events()
tool_events = [e for e in events if e["type"] == proto.S_TOOL_EVENT]
assert len(tool_events) == 1
ev = tool_events[0]
assert ev["tool_name"] == "web_search"
assert ev["status"] == "running"
assert ev["tool_call_id"] == "call-1"
assert ev["conversation_id"] == "conv1"
# No fake prose leaked into a message event
assert not any(e.get("type") == proto.S_MESSAGE_COMPLETE
for e in events)
@pytest.mark.asyncio
async def test_post_tool_call_completion(self, tmp_path, monkeypatch):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
server._clients["t"] = client
from pheby.adapter import PhebyAdapter
from gateway.config import PlatformConfig
real = PhebyAdapter(PlatformConfig(
enabled=True, extra={"secret": "test-secret-abc123"}))
real._server = server
real._loop = asyncio.get_running_loop()
real._active_sessions = {"agent:main:pheby:dm:conv1": asyncio.Event()}
monkeypatch.setattr(real, "_conversation_for_session_id",
lambda _sid: "conv1")
real.on_post_tool_call(
session_id="session-1", tool_name="terminal", tool_call_id="call_9",
status="ok", duration_ms=1234)
await asyncio.sleep(0) # let ensure_future run
ev = [e for e in client.ws.events()
if e["type"] == proto.S_TOOL_EVENT][-1]
assert ev["tool_call_id"] == "call_9"
assert ev["status"] == "completed"
assert ev["duration_ms"] == 1234
@pytest.mark.asyncio
async def test_approval_push_and_resolve_roundtrip(self, tmp_path,
monkeypatch):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
server._clients["t"] = client
from pheby import hermes_bridge
await hermes_bridge.push_approval(
{"command": "rm -rf /tmp/x", "description": "Destructive command",
"allow_permanent": True, "allow_session": True},
session_key="agent:main:pheby:dm:conv1")
req = [e for e in client.ws.events()
if e["type"] == proto.S_APPROVAL_REQUEST][-1]
assert req["choices"] == ["once", "session", "always", "deny"]
assert req["description"] == "Destructive command"
from pheby import hermes_bridge as hb
import tools.approval
monkeypatch.setattr(tools.approval, "resolve_gateway_approval",
lambda *args, **kwargs: 1)
ok = await hb.resolve_approval(req["approval_id"], "deny", None)
assert ok is True
# Double resolve → not found
ok2 = await hb.resolve_approval(req["approval_id"], "once", None)
assert ok2 is False
# Client-facing error path via server handler
await server._handle_approval_respond(client, {
"type": proto.C_APPROVAL_RESPOND,
"approval_id": "nope", "choice": "once"}, "r1")
ev = client.ws.events()[-1]
assert ev["error"]["code"] == proto.ERR_APPROVAL_NOT_FOUND
@pytest.mark.asyncio
async def test_yolo_state_is_conversation_scoped_and_persisted(self, monkeypatch):
from pheby import hermes_bridge as hb
import tools.approval
conversation_id = "a" * 32
session_key = f"agent:main:pheby:dm:{conversation_id}"
persisted = []
class Store:
def peek_session_id(self, key):
assert key == session_key
return "session-1"
class DB:
def set_session_yolo(self, session_id, enabled):
persisted.append((session_id, enabled))
monkeypatch.setattr(hb, "_session_store", lambda: Store())
monkeypatch.setattr(hb, "_session_db", lambda: DB())
tools.approval.clear_session(session_key)
try:
assert (await hb.yolo_snapshot(conversation_id))["enabled"] is False
enabled = await hb.set_yolo(conversation_id, True)
assert enabled == {
"ok": True,
"enabled": True,
"scope": "conversation",
"conversation_id": conversation_id,
}
assert tools.approval.is_session_yolo_enabled(session_key) is True
assert (await hb.yolo_snapshot(conversation_id))["enabled"] is True
disabled = await hb.set_yolo(conversation_id, False)
assert disabled["enabled"] is False
assert tools.approval.is_session_yolo_enabled(session_key) is False
assert persisted == [("session-1", True), ("session-1", False)]
finally:
tools.approval.clear_session(session_key)
@pytest.mark.asyncio
async def test_yolo_websocket_handlers_return_snapshot_and_changed(self, tmp_path,
monkeypatch):
server = make_server(tmp_path)
client = FakeClientConnection()
conversation_id = "b" * 32
calls = []
async def snapshot(cid):
calls.append(("current", cid))
return {"ok": True, "enabled": False, "scope": "conversation",
"conversation_id": cid}
async def change(cid, enabled):
calls.append(("set", cid, enabled))
return {"ok": True, "enabled": enabled, "scope": "conversation",
"conversation_id": cid}
monkeypatch.setattr(server.bridge, "yolo_snapshot", snapshot)
monkeypatch.setattr(server.bridge, "set_yolo", change)
await server._handle_yolo_current(client, {
"type": proto.C_YOLO_CURRENT,
"conversation_id": conversation_id,
}, "r-yolo-current")
await server._handle_yolo_set(client, {
"type": proto.C_YOLO_SET,
"conversation_id": conversation_id,
"enabled": True,
}, "r-yolo-set")
events = client.ws.events()
assert events[0]["type"] == proto.S_YOLO_SNAPSHOT
assert events[0]["request_id"] == "r-yolo-current"
assert events[1]["type"] == proto.S_YOLO_CHANGED
assert events[1]["enabled"] is True
assert events[1]["request_id"] == "r-yolo-set"
assert calls == [("current", conversation_id), ("set", conversation_id, True)]
@pytest.mark.asyncio
async def test_clarify_push_and_resolve_roundtrip(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
server._clients["t"] = client
from pheby import hermes_bridge
await hermes_bridge.push_clarify(
"clar1", "sk", "Deploy where?", ["staging", "prod"])
req = [e for e in client.ws.events()
if e["type"] == proto.S_CLARIFY_REQUEST][-1]
assert req["question"] == "Deploy where?"
assert req["choices"] == ["staging", "prod"]
assert req["allow_free_text"] is True
# Register the clarify in Hermes's real gateway primitive so the
# full resolve path (tools.clarify_gateway) is exercised.
from tools import clarify_gateway as cg
cg.register(clarify_id="clar1", session_key="sk",
question="Deploy where?", choices=["staging", "prod"])
from pheby import hermes_bridge as hb
ok = await hb.resolve_clarify("clar1", "staging")
assert ok is True
ok2 = await hb.resolve_clarify("clar1", "staging")
assert ok2 is False # entry consumed
cg.clear_session("sk")
@pytest.mark.asyncio
async def test_chat_send_creates_message_event(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
server._clients["t"] = client
from pheby import hermes_bridge as hb
await hb.send_chat(server, "conv77", "hello Hermes", client, "r1")
adapter = server.adapter
assert len(adapter.handled) == 1
assert adapter.handled[0].text == "hello Hermes"
assert adapter.handled[0].source.chat_id == "conv77"
events = client.ws.events()
assert events[0]["type"] == proto.S_RUN_ACCEPTED
assert events[1]["type"] == proto.S_MESSAGE_START
@pytest.mark.asyncio
async def test_chat_handler_passes_server_to_bridge(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
cid = "a" * 32
await server._handle_chat_send(client, {
"type": proto.C_CHAT_SEND,
"conversation_id": cid,
"text": "hello through WebSocket",
}, "request-handler")
assert server.adapter.handled[-1].text == "hello through WebSocket"
assert server.adapter.handled[-1].source.chat_id == cid
assert client.ws.events()[0]["type"] == proto.S_RUN_ACCEPTED
@pytest.mark.asyncio
async def test_chat_dispatch_failure_closes_run(self, tmp_path,
monkeypatch):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
server._clients["t"] = client
cid = "b" * 32
async def reject(_event):
raise RuntimeError("gateway unavailable")
monkeypatch.setattr(server.adapter, "handle_message", reject)
from pheby import hermes_bridge as hb
with pytest.raises(RuntimeError, match="gateway unavailable"):
await hb.send_chat(server, cid, "hello", client, "request")
assert hb.active_run(cid) is None
assert client.ws.events()[-1]["type"] == proto.S_RUN_FINISHED
assert client.ws.events()[-1]["status"] == "failed"
@pytest.mark.asyncio
async def test_delete_removes_route_instead_of_resetting(self, tmp_path,
monkeypatch):
server = make_server(tmp_path)
cid = await server.router.new_conversation("Delete me")
session_key = f"agent:main:pheby:dm:{cid}"
class Entry:
display_name = "Delete me"
class Store:
def __init__(self):
self._lock = threading.Lock()
self._entries = {session_key: Entry()}
self.saved = False
def _ensure_loaded_locked(self):
return None
def _save(self):
self.saved = True
def peek_session_id(self, key):
return "session-delete" if key == session_key else None
class DB:
deleted = None
def delete_session(self, session_id):
self.deleted = session_id
return True
store, db = Store(), DB()
from pheby import hermes_bridge as hb
monkeypatch.setattr(hb, "_session_store", lambda: store)
monkeypatch.setattr(hb, "_session_db", lambda: db)
assert await hb.delete_conversation(cid) is True
assert db.deleted == "session-delete"
assert session_key not in store._entries
assert store.saved is True
assert await server.router.get_name(cid) is None
@pytest.mark.asyncio
async def test_models_snapshot_uses_current_hermes_signature(
self, tmp_path, monkeypatch):
make_server(tmp_path)
captured = {}
def fake_list_picker_providers(**kwargs):
captured.update(kwargs)
return [{"slug": "test", "models": ["m1"]}]
from pheby import hermes_bridge as hb
import hermes_cli.config as hermes_config
import hermes_cli.model_switch_providers as model_switch_providers
monkeypatch.setattr(hb, "_load_cfg", lambda: {
"model": {"default": "m1", "provider": "test"},
"providers": {"test": {"base_url": "http://example"}},
"model_catalog": {"excluded_providers": ["hidden"]},
})
monkeypatch.setattr(
hermes_config, "get_compatible_custom_providers",
lambda _cfg: [{"name": "test", "base_url": "http://example"}])
monkeypatch.setattr(
model_switch_providers, "list_picker_providers", fake_list_picker_providers)
snapshot = await hb.models_snapshot()
assert snapshot["providers"][0]["slug"] == "test"
assert captured["current_model"] == "m1"
assert captured["excluded_providers"] == ["hidden"]
assert captured["custom_providers"][0]["name"] == "test"
@pytest.mark.asyncio
async def test_model_set_uses_profile_secret_scope_and_current_result_fields(
self, tmp_path, monkeypatch):
from agent.secret_scope import (
get_secret, reset_secret_scope, set_multiplex_active,
set_secret_scope,
)
from pheby import hermes_bridge as hb
import hermes_cli.config as hermes_config
import hermes_cli.model_switch as model_switch
(tmp_path / ".env").write_text(
"OPENROUTER_API_KEY=scoped-openrouter-key\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(hb, "_load_cfg", lambda: {
"model": {"default": "gpt-old", "provider": "openai-codex"},
})
monkeypatch.setattr(
hermes_config, "get_compatible_custom_providers", lambda _cfg: [])
def fake_switch_model(*_args, **_kwargs):
assert get_secret("OPENROUTER_API_KEY") == "scoped-openrouter-key"
return SimpleNamespace(
success=True,
new_model="z-ai/glm-5.3-flash",
target_provider="openrouter",
api_key="scoped-openrouter-key",
base_url="https://openrouter.ai/api/v1",
api_mode="chat_completions",
request_overrides={},
runtime_capabilities={"native_compaction": False},
)
monkeypatch.setattr(model_switch, "switch_model", fake_switch_model)
class Store:
saved = None
def peek_session_id(self, _session_key):
return "session-1"
def set_model_override(self, _session_key, override):
self.saved = override
store = Store()
class Runner:
def __init__(self):
self._session_model_overrides = {}
self.evicted = []
def _evict_cached_agent(self, session_key):
self.evicted.append(session_key)
runner = Runner()
monkeypatch.setattr(hb, "_session_store", lambda: store)
monkeypatch.setattr(hb, "_runner", lambda: runner)
outer_token = set_secret_scope(None)
set_multiplex_active(True)
try:
result = await hb.set_model("glm-flash", "openrouter", "conv")
finally:
set_multiplex_active(False)
reset_secret_scope(outer_token)
assert result == {
"ok": True,
"model": "z-ai/glm-5.3-flash",
"provider": "openrouter",
"scope": "conversation",
}
assert store.saved == {
"model": "z-ai/glm-5.3-flash",
"provider": "openrouter",
}
assert runner._session_model_overrides[
"agent:main:pheby:dm:conv"] == {
"model": "z-ai/glm-5.3-flash",
"provider": "openrouter",
"api_key": "scoped-openrouter-key",
"base_url": "https://openrouter.ai/api/v1",
"api_mode": "chat_completions",
"request_overrides": {},
"capabilities": {"native_compaction": False},
}
assert runner.evicted == ["agent:main:pheby:dm:conv"]
@pytest.mark.asyncio
async def test_model_set_surfaces_current_hermes_error_message(
self, monkeypatch):
from pheby import hermes_bridge as hb
import hermes_cli.config as hermes_config
import hermes_cli.model_switch as model_switch
monkeypatch.setattr(hb, "_load_cfg", lambda: {"model": {}})
monkeypatch.setattr(
hermes_config, "get_compatible_custom_providers", lambda _cfg: [])
monkeypatch.setattr(
model_switch,
"switch_model",
lambda *_args, **_kwargs: SimpleNamespace(
success=False,
error_message="OpenRouter credentials unavailable",
),
)
result = await hb.set_model("z-ai/glm-5.3-flash", "openrouter", None)
assert result["ok"] is False
assert result["code"] == proto.ERR_BAD_REQUEST
assert result["message"] == "OpenRouter credentials unavailable"
@pytest.mark.asyncio
async def test_cancel_run_interrupts_agent(self, tmp_path):
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
server._clients["t"] = client
runner = FakeRunner()
agent = FakeAgent()
runner._running_agents["agent:main:pheby:dm:convX"] = agent
server.adapter.gateway_runner = runner
from pheby import hermes_bridge as hb
hb._ACTIVE_RUNS["convX"] = {"run_id": "run-x", "started": 0}
ok = await hb.cancel_run("convX", None)
assert ok is True
assert agent.interrupts # agent.interrupt called, not thread-kill
assert runner.generations.get("agent:main:pheby:dm:convX") == 1
@pytest.mark.asyncio
async def test_cancel_stale_run_id_rejected(self, tmp_path):
server = make_server(tmp_path)
from pheby import hermes_bridge as hb
server._bridge_active("convY") if hasattr(
server, "_bridge_active") else None
hb._ACTIVE_RUNS["convY"] = {"run_id": "run1", "started": 0}
ok = await hb.cancel_run("convY", "wrong-run")
assert ok is False
@pytest.mark.asyncio
async def test_reasoning_set_validation(self, tmp_path):
from pheby import hermes_bridge as hb
bad = await hb.set_reasoning("turbo", None)
assert bad["ok"] is False
assert bad["code"] == proto.ERR_BAD_REQUEST
# ═══════════════════════════════════════════════════════════════════════════
# Reconnect / recovery semantics
# ═══════════════════════════════════════════════════════════════════════════
class TestRecovery:
@pytest.mark.asyncio
async def test_history_resync_after_reconnect(self, tmp_path):
"""Conversation state is authoritative server-side: a fresh client
connection re-opening a conversation gets the same history."""
server = make_server(tmp_path)
cid = await server.router.new_conversation("Sync")
# Seed transcript via the fake DB path is covered in bridge tests
# through Hermes; here assert the contract: open is idempotent.
c1, c2 = FakeClientConnection(), FakeClientConnection()
for c in (c1, c2):
c.authenticated = True
await server._handle_conversation_open(c1, {
"type": proto.C_CONVERSATION_OPEN, "conversation_id": cid}, "a")
await server._handle_conversation_open(c2, {
"type": proto.C_CONVERSATION_OPEN, "conversation_id": cid}, "b")
h1 = c1.ws.events()[-1]
h2 = c2.ws.events()[-1]
assert h1["messages"] == h2["messages"]
assert h1["conversation_id"] == h2["conversation_id"] == cid
@pytest.mark.asyncio
async def test_open_recovers_runtime_and_attachment_state(self, tmp_path):
server = make_server(tmp_path)
cid = await server.router.new_conversation("Recover")
source = Path(tmp_path) / "recovery.png"
source.write_bytes(b"\x89PNG recovery")
attachment = await server.store.register_file(
str(source), conversation_id=cid)
from pheby import hermes_bridge as hb
hb._ACTIVE_RUNS[cid] = {"run_id": "run-recover", "started": 1}
hb.record_tool_event(cid, {
"type": proto.S_TOOL_EVENT,
"conversation_id": cid,
"run_id": "run-recover",
"tool_call_id": "tool-recover",
"tool_name": "terminal",
"status": "running",
})
await hb.push_approval(
{"command": "echo hi", "description": "Run command"},
f"agent:main:pheby:dm:{cid}")
await hb.push_clarify(
"clarify-recover", f"agent:main:pheby:dm:{cid}",
"Continue?", ["yes", "no"])
client = FakeClientConnection()
client.authenticated = True
await server._handle_conversation_open(client, {
"type": proto.C_CONVERSATION_OPEN,
"conversation_id": cid,
}, "recover")
snapshot = client.ws.events()[-1]
assert snapshot["attachments"][0]["attachment_id"] == \
attachment["attachment_id"]
assert snapshot["run"]["run_id"] == "run-recover"
assert snapshot["tools"][0]["tool_call_id"] == "tool-recover"
assert snapshot["approvals"][0]["conversation_id"] == cid
assert snapshot["clarifications"][0]["clarify_id"] == \
"clarify-recover"
@pytest.mark.asyncio
async def test_broadcast_reaches_multiple_clients(self, tmp_path):
server = make_server(tmp_path)
clients = []
for i in range(3):
c = FakeClientConnection()
c.authenticated = True
server._clients[f"c{i}"] = c
clients.append(c)
await server.broadcast({"type": proto.S_PONG, "ts": "t"})
for c in clients:
assert any(e["type"] == proto.S_PONG for e in c.ws.events())
# ═══════════════════════════════════════════════════════════════════════════
# Config
# ═══════════════════════════════════════════════════════════════════════════
class TestConfig:
def test_env_secret_wins(self, monkeypatch):
monkeypatch.setenv("PHEBY_SECRET", "env-secret")
cfg = load_config({"secret": "yaml-secret", "port": 9999})
assert cfg.secret == "env-secret"
def test_yaml_fallback_and_defaults(self, monkeypatch):
monkeypatch.delenv("PHEBY_SECRET", raising=False)
cfg = load_config({"secret": "yaml-secret"})
assert cfg.secret == "yaml-secret"
assert cfg.bind_host == "127.0.0.1"
assert cfg.port == 8620
assert cfg.retention_days == 7
assert cfg.enabled
def test_disabled_without_secret(self, monkeypatch):
monkeypatch.delenv("PHEBY_SECRET", raising=False)
cfg = load_config({})
assert not cfg.enabled
def test_bad_port_falls_back(self, monkeypatch):
monkeypatch.delenv("PHEBY_SECRET", raising=False)
cfg = load_config({"secret": "s", "port": "not-a-port"})
assert cfg.port == 8620
# ═══════════════════════════════════════════════════════════════════════════
# Live HTTP+WS smoke (localhost only)
# ═══════════════════════════════════════════════════════════════════════════
class TestLiveServer:
@pytest.mark.asyncio
async def test_health_and_ws_roundtrip(self, tmp_path):
try:
import aiohttp
except ImportError:
pytest.skip("aiohttp unavailable")
server = make_server(tmp_path, port=0)
# Bind on an ephemeral port by patching TCPSite port choice.
cfg = server.config
cfg.port = 0 # let OS choose
ok = await server.start()
if not ok:
pytest.skip("could not bind test server")
try:
port = server._site._server.sockets[0].getsockname()[1]
base = f"http://127.0.0.1:{port}"
async with aiohttp.ClientSession() as http:
# health: no auth
async with http.get(f"{base}/health") as resp:
assert resp.status == 200
data = await resp.json()
assert data["status"] == "ok"
# attachment without auth → 401
async with http.get(
f"{base}/attachments/{'a'*32}") as resp:
assert resp.status == 401
# WS handshake with bad secret → server sends error event
async with http.ws_connect(f"{base}/ws") as ws:
await ws.send_str(json.dumps(
{"type": "hello", "secret": "bad"}))
msg = await ws.receive()
reply = json.loads(msg.data)
assert reply["type"] == proto.S_ERROR
assert reply["error"]["code"] == proto.ERR_UNAUTHORIZED
finally:
await server.stop()
@pytest.mark.asyncio
async def test_full_ws_flow(self, tmp_path):
"""hello → ready → ping/pong → conversation create → list."""
try:
import aiohttp
except ImportError:
pytest.skip("aiohttp unavailable")
server = make_server(tmp_path)
cfg = server.config
cfg.port = 0
ok = await server.start()
if not ok:
pytest.skip("could not bind test server")
try:
port = server._site._server.sockets[0].getsockname()[1]
async with aiohttp.ClientSession() as http:
async with http.ws_connect(
f"http://127.0.0.1:{port}/ws") as ws:
await ws.send_str(json.dumps({
"type": "hello",
"secret": "test-secret-abc123",
"protocol_version": proto.PROTOCOL_VERSION}))
ready = json.loads((await ws.receive()).data)
assert ready["type"] == proto.S_READY
await ws.send_str(json.dumps({"type": "ping"}))
pong = json.loads((await ws.receive()).data)
assert pong["type"] == proto.S_PONG
await ws.send_str(json.dumps({
"type": "conversation.create", "name": "Live",
"request_id": "r1"}))
created = json.loads((await ws.receive()).data)
assert created["type"] == proto.S_CONVERSATION_CREATED
assert created["request_id"] == "r1"
cid = created["conversation_id"]
# The handler also broadcasts a conversation.updated event
updated = json.loads((await ws.receive()).data)
assert updated["type"] == proto.S_CONVERSATION_UPDATED
await ws.send_str(json.dumps({
"type": "conversation.list", "request_id": "r2"}))
snap = json.loads((await ws.receive()).data)
assert any(c["conversation_id"] == cid
for c in snap["conversations"])
# unknown type → structured error
await ws.send_str(json.dumps({"type": "bogus.thing"}))
err = json.loads((await ws.receive()).data)
assert err["type"] == proto.S_ERROR
assert err["error"]["code"] == proto.ERR_UNKNOWN_TYPE
finally:
await server.stop()
# ═══════════════════════════════════════════════════════════════════════════
# Adapter unit checks
# ═══════════════════════════════════════════════════════════════════════════
class TestAdapterUnits:
@pytest.mark.asyncio
async def test_stream_preview_keeps_run_open_until_finalize(self, tmp_path):
from pheby.adapter import PhebyAdapter
from gateway.config import PlatformConfig
from pheby import hermes_bridge as hb
server = make_server(tmp_path)
client = FakeClientConnection()
client.authenticated = True
server._clients["t"] = client
adapter = PhebyAdapter(PlatformConfig(
enabled=True, extra={"secret": "test-secret-abc123"}))
adapter._server = server
adapter._loop = asyncio.get_running_loop()
adapter._drafts["conv"] = {"message_id": "draft-run", "text": ""}
hb.set_adapter(adapter)
hb.set_server(server)
hb._ACTIVE_RUNS["conv"] = {"run_id": "run-1", "started": 0}
first = await adapter.send("conv", "hel", metadata={"expect_edits": True})
assert first.message_id == "draft-run"
assert hb.active_run_id("conv") == "run-1"
assert client.ws.events()[-1]["type"] == proto.S_MESSAGE_DELTA
final = await adapter.edit_message(
"conv", "draft-run", "hello", finalize=True)
await asyncio.sleep(0)
assert final.message_id == "draft-run"
assert hb.active_run_id("conv") is None
assert [e["type"] for e in client.ws.events()][-2:] == [
proto.S_MESSAGE_COMPLETE, proto.S_RUN_FINISHED]
def test_transport_auth_is_gateway_authorization(self):
from pheby.adapter import PhebyAdapter
from gateway.config import PlatformConfig
adapter = PhebyAdapter(PlatformConfig(
enabled=True, extra={"secret": "test-secret-abc123"}))
assert adapter.authorization_is_upstream is True
def test_redact_args(self):
from pheby.adapter import _redact_args
out = _redact_args({"query": "cats", "api_key": "sk-123",
"token": "t", "long": "x" * 900})
assert out["api_key"] == "[redacted]"
assert out["token"] == "[redacted]"
assert out["query"] == "cats"
assert out["long"].endswith("…")
def test_redact_args_recursively(self):
from pheby.adapter import _redact_args
out = _redact_args({
"headers": {"Authorization": "Bearer secret"},
"steps": [{"password": "hunter2", "value": "safe"}],
})
assert out["headers"]["Authorization"] == "[redacted]"
assert out["steps"][0]["password"] == "[redacted]"
assert out["steps"][0]["value"] == "safe"
def test_sanitize_filename(self):
from pheby.attachments import AttachmentStore
assert AttachmentStore._sanitize_filename("../../etc/passwd") == "passwd"
# Path separators (either flavor) collapse to the final component.
assert AttachmentStore._sanitize_filename("a/b\\c.txt") == "c.txt"
assert AttachmentStore._sanitize_filename("") == "file.bin"