"""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_invalid_history_cursor_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": "conv", "before_message_id": "m0 OR 1=1"}, "r1") assert client.ws.events()[-1]["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 _read_all(self, sql, params): return [{"id": "session-1"}] def get_messages_as_conversation(self, _session_id, include_row_ids=False, include_compacted=False): assert include_row_ids is True assert include_compacted 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, older = await hb.conversation_history("conv", 20) assert found is True assert older is False 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" @pytest.mark.asyncio async def test_history_spans_reset_sessions_and_pages_without_duplicates(self, monkeypatch): from pheby import hermes_bridge as hb class Store: def peek_session_id(self, _key): return "new" class DB: def _read_all(self, sql, params): assert "session_key" in sql and params[1] == "pheby" return [{"id": "old"}, {"id": "new"}] def get_messages_as_conversation(self, sid, **kwargs): assert kwargs == {"include_row_ids": True, "include_compacted": True} start = 1 if sid == "old" else 4 return [{"_row_id": n, "role": "user", "content": f"turn {n}", "timestamp": float(n)} for n in range(start, start + 3)] monkeypatch.setattr(hb, "_session_store", lambda: Store()) monkeypatch.setattr(hb, "_session_db", lambda: DB()) latest, found, older = await hb.conversation_history("conv", 2) assert found and older assert [m["message_id"] for m in latest] == ["m5", "m6"] previous, found, older = await hb.conversation_history("conv", 2, before_id=5) assert found and older assert [m["message_id"] for m in previous] == ["m3", "m4"] first, found, older = await hb.conversation_history("conv", 2, before_id=3) assert found and not older assert [m["message_id"] for m in first] == ["m1", "m2"] @pytest.mark.asyncio async def test_history_read_failure_is_not_reported_as_empty_chat(self, monkeypatch): from pheby import hermes_bridge as hb class DB: def _read_all(self, _sql, _params): raise OSError("database offline") monkeypatch.setattr(hb, "_session_store", lambda: None) monkeypatch.setattr(hb, "_session_db", lambda: DB()) with pytest.raises(RuntimeError, match="transcript"): await hb.conversation_history("conv", 20) 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 _read_all(self, sql, params): assert "session_key" in sql and params == (session_key, "pheby") return [{"id": "session-old"}, {"id": "session-delete"}] def delete_sessions(self, session_ids): self.deleted = list(session_ids) return len(session_ids) 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-old", "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"