1147 lines
49 KiB
Python
1147 lines
49 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 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"
|
|
|
|
@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_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"
|