Gateway (docs/19): - Remove ws_server.py; frame dispatch factored into dispatch.py - http_server: media upload/pull, pairing over HTTP - protocol: media frames mirrored; tests + ws_probe updated for HTTP App: - HttpGateway: postFrame/uploadMedia/pullMedia no longer throw on network failure (PostResult ok=false / Result.failure) — uncaught SocketTimeoutException on Dispatchers.Default crashed the app - GatewayClient: dead-stream watchdog (health probe every 10s, 2 failures -> redial in ~20s instead of the 45s SSE read timeout); state flips to Reconnecting when the stream dies, restored from the last hello.ack on long-poll success; poke() + backoff reset on app resume (MainActivity.onResume) - Offline sends: composer enabled while disconnected; a send with no response (status 0) stays queued (Pending) and is auto-resent on the next (re)connect after a 2s outbox-replay grace; gateway 4xx rejections fail the bubble (tap to retry, no auto-loop) - ChatStore: echo-replace and thread-relocate also match Failed bubbles (POST response lost in a network drop); loadHistory dedupes local failed bubbles the server already has; failMessage() - MainActivity: poke() on resume so a backgrounded app reconnects promptly instead of waiting out the backoff
2339 lines
88 KiB
Python
2339 lines
88 KiB
Python
"""Tests for the Iris x Hermes android gateway plugin (M4: media).
|
|
|
|
The plugin lives in the sibling ``iris_x_hermes`` checkout (installed into
|
|
``~/.hermes/plugins/android`` as a symlink in production); tests load it
|
|
from the source tree directly so they never depend on that install.
|
|
|
|
Coverage (docs/13-testing.md §13.1, media bullets):
|
|
* upload via ``POST /v1/media`` reassembles + sha256 verified
|
|
* over-limit (Content-Length) -> ``media_too_large``
|
|
* sha256 mismatch -> ``internal``
|
|
* ``message.send`` with ``media_refs`` -> echo carries ``media[]`` and the
|
|
``MessageEvent`` carries ``media_urls``/``media_types``
|
|
* ``send_*`` -> ``media.offer`` (fields + message association)
|
|
* ``GET /v1/media/{id}`` serves only allowed paths (denied/unknown -> ``not_found``)
|
|
* kind re-sniffing (don't trust the client)
|
|
|
|
Run via ``scripts/run_tests.sh tests/gateway/test_android.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import sys
|
|
import socket
|
|
import threading
|
|
from http.client import HTTPConnection
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
# 1x1 red PNG (67 bytes) -- passes hermes' image magic-byte check.
|
|
PNG_1X1 = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
|
"AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
|
)
|
|
|
|
TOKEN = "test-android-token-0123456789"
|
|
DEVICE_ID = "test-device"
|
|
|
|
|
|
def _plugin_dir() -> Path:
|
|
env = os.environ.get("ANDROID_PLUGIN_DIR")
|
|
if env:
|
|
return Path(env)
|
|
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
|
|
# plugin dir is parents[1]) or the hermes-agent/tests/gateway/ mirror
|
|
# (repo root is parents[3]).
|
|
here = Path(__file__).resolve()
|
|
for candidate in (here.parents[1], here.parents[3] / "gateway-plugin"):
|
|
if (candidate / "protocol.py").is_file():
|
|
return candidate
|
|
return here.parents[1]
|
|
|
|
|
|
def _load_plugin():
|
|
"""Load the gateway-plugin package under a unique module name.
|
|
|
|
The plugin uses relative imports (``from . import protocol``), so it
|
|
must be imported as a package (``submodule_search_locations``).
|
|
"""
|
|
name = "android_plugin_under_test"
|
|
cached = sys.modules.get(name)
|
|
if cached is not None:
|
|
return cached
|
|
pkg_dir = _plugin_dir()
|
|
if not (pkg_dir / "__init__.py").is_file():
|
|
pytest.fail(f"android plugin not found at {pkg_dir}")
|
|
spec = importlib.util.spec_from_file_location(
|
|
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
|
|
)
|
|
if spec is None or spec.loader is None:
|
|
pytest.fail(f"could not build import spec for {pkg_dir}")
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[name] = module
|
|
try:
|
|
spec.loader.exec_module(module)
|
|
except Exception:
|
|
sys.modules.pop(name, None)
|
|
raise
|
|
return module
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def plugin():
|
|
return _load_plugin()
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter(plugin, monkeypatch):
|
|
"""A live AndroidAdapter with an isolated HERMES_HOME (conftest)."""
|
|
monkeypatch.setenv("ANDROID_TOKEN", TOKEN)
|
|
from gateway.platform_registry import PlatformEntry, platform_registry
|
|
|
|
# Platform("android") resolves only once the platform is registered
|
|
# (the plugin's register(ctx) does this in production).
|
|
if not platform_registry.is_registered("android"):
|
|
platform_registry.register(
|
|
PlatformEntry(
|
|
name="android",
|
|
label="Android",
|
|
adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: True,
|
|
)
|
|
)
|
|
|
|
config = SimpleNamespace(
|
|
extra={
|
|
"host": "127.0.0.1",
|
|
"http_port": 0, # ephemeral HTTP port
|
|
"max_upload_bytes": 1024 * 1024, # 1 MiB -- keeps over-limit tests fast
|
|
},
|
|
home_channel=None,
|
|
)
|
|
a = plugin.adapter.AndroidAdapter(config)
|
|
yield a
|
|
try:
|
|
a._devices.close()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
a._outbox.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class HttpTestClient:
|
|
"""Mimics the old WS client interface over the HTTP transport (docs/19).
|
|
|
|
``.send(json_str)`` -> ``POST /v1/frame``; ``.recv(timeout)`` -> the next
|
|
frame from the SSE stream (a dict); ``.upload(...)`` -> ``POST /v1/media``
|
|
returning the terminal frame (ack or error). The SSE stream is read in a
|
|
background thread that feeds an asyncio queue, so ``recv`` is awaitable
|
|
exactly like the old WS ``recv``.
|
|
"""
|
|
|
|
def __init__(self, port: int, cursor: int = 0):
|
|
self._port = port
|
|
self._cursor = cursor
|
|
self._queue: asyncio.Queue = asyncio.Queue()
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
self._thread: threading.Thread | None = None
|
|
self._resp = None
|
|
|
|
async def start(self) -> dict:
|
|
"""Open the SSE stream; returns the hello.ack frame. Any outbox
|
|
catch-up frames replayed before the hello are buffered and re-enqueued
|
|
so ``recv`` can still see them."""
|
|
self._loop = asyncio.get_running_loop()
|
|
self._thread = threading.Thread(target=self._sse_reader, daemon=True)
|
|
self._thread.start()
|
|
buffered: list = []
|
|
hello = None
|
|
while hello is None:
|
|
frame = await asyncio.wait_for(self._queue.get(), timeout=5)
|
|
if frame is None:
|
|
raise AssertionError("SSE stream closed before hello")
|
|
if frame.get("type") == "hello.ack":
|
|
hello = frame
|
|
else:
|
|
buffered.append(frame)
|
|
for f in buffered:
|
|
self._queue.put_nowait(f)
|
|
return hello
|
|
|
|
def _sse_reader(self) -> None:
|
|
conn = HTTPConnection("127.0.0.1", self._port, timeout=30)
|
|
conn.request(
|
|
"GET",
|
|
f"/v1/events?cursor={self._cursor}",
|
|
headers={"Authorization": f"Bearer {TOKEN}", "X-Iris-Device": DEVICE_ID},
|
|
)
|
|
resp = conn.getresponse()
|
|
self._resp = resp
|
|
cur_data: list[str] = []
|
|
loop = self._loop
|
|
assert loop is not None
|
|
while True:
|
|
line = resp.fp.readline()
|
|
if not line:
|
|
break
|
|
line = line.decode("utf-8").rstrip("\r\n")
|
|
if line == "":
|
|
if cur_data:
|
|
try:
|
|
frame = json.loads("\n".join(cur_data))
|
|
except Exception:
|
|
frame = None
|
|
if frame is not None:
|
|
loop.call_soon_threadsafe(self._queue.put_nowait, frame)
|
|
cur_data = []
|
|
elif line.startswith(":"):
|
|
continue # heartbeat comment
|
|
elif line.startswith("data:"):
|
|
cur_data.append(line[5:].lstrip())
|
|
# id:/event: fields are not needed for the test shim
|
|
loop.call_soon_threadsafe(self._queue.put_nowait, None) # EOF sentinel
|
|
|
|
async def send(self, json_str: str) -> None:
|
|
await asyncio.to_thread(self._post_frame, json_str)
|
|
|
|
def _post_frame(self, json_str: str) -> None:
|
|
conn = HTTPConnection("127.0.0.1", self._port, timeout=30)
|
|
conn.request(
|
|
"POST",
|
|
"/v1/frame",
|
|
body=json_str.encode("utf-8"),
|
|
headers={
|
|
"Authorization": f"Bearer {TOKEN}",
|
|
"X-Iris-Device": DEVICE_ID,
|
|
"Content-Type": "application/json",
|
|
},
|
|
)
|
|
resp = conn.getresponse()
|
|
body = resp.read()
|
|
conn.close()
|
|
# Fast responses (validation, channel ops, …) come back on the POST
|
|
# response body via the reply sink, NOT the SSE stream. Enqueue any
|
|
# protocol frame so recv_until sees it (the plain {"ok":true} ack is
|
|
# not a frame and is skipped).
|
|
if body:
|
|
try:
|
|
obj = json.loads(body)
|
|
except Exception:
|
|
obj = None
|
|
if isinstance(obj, dict) and "type" in obj:
|
|
assert self._loop is not None
|
|
self._loop.call_soon_threadsafe(self._queue.put_nowait, obj)
|
|
|
|
async def recv(self, timeout: float = 10.0) -> dict:
|
|
frame = await asyncio.wait_for(self._queue.get(), timeout=timeout)
|
|
if frame is None:
|
|
raise ConnectionError("SSE stream closed")
|
|
return frame
|
|
|
|
async def upload(
|
|
self,
|
|
media_ref: str,
|
|
data: bytes,
|
|
*,
|
|
kind: str = "image",
|
|
mime: str = "image/png",
|
|
filename: str = "t.png",
|
|
sha256: str | None = None,
|
|
) -> dict:
|
|
"""Drive a media upload via ``POST /v1/media``; returns the terminal
|
|
frame (``media.upload.ack`` or ``error``)."""
|
|
|
|
def _do() -> dict:
|
|
conn = HTTPConnection("127.0.0.1", self._port, timeout=60)
|
|
conn.request(
|
|
"POST",
|
|
"/v1/media",
|
|
body=data,
|
|
headers={
|
|
"Authorization": f"Bearer {TOKEN}",
|
|
"X-Iris-Device": DEVICE_ID,
|
|
"Content-Type": mime,
|
|
"X-Iris-Media-Ref": media_ref,
|
|
"X-Iris-Media-Kind": kind,
|
|
"X-Iris-Media-Filename": filename,
|
|
"X-Iris-Media-Sha256": sha256
|
|
or hashlib.sha256(data).hexdigest(),
|
|
},
|
|
)
|
|
resp = conn.getresponse()
|
|
body = resp.read()
|
|
conn.close()
|
|
return json.loads(body)
|
|
|
|
return await asyncio.to_thread(_do)
|
|
|
|
async def pull(self, media_id: str) -> tuple[int, bytes]:
|
|
"""Drive a media pull via ``GET /v1/media/{id}``; returns (status, body)."""
|
|
|
|
def _do() -> tuple[int, bytes]:
|
|
conn = HTTPConnection("127.0.0.1", self._port, timeout=30)
|
|
conn.request(
|
|
"GET",
|
|
f"/v1/media/{media_id}",
|
|
headers={"Authorization": f"Bearer {TOKEN}", "X-Iris-Device": DEVICE_ID},
|
|
)
|
|
resp = conn.getresponse()
|
|
body = resp.read()
|
|
status = resp.status
|
|
conn.close()
|
|
return status, body
|
|
|
|
return await asyncio.to_thread(_do)
|
|
|
|
async def close(self) -> None:
|
|
# Interrupt the reader thread's blocking readline() by shutting down
|
|
# the socket first; otherwise resp.close() blocks until the in-flight
|
|
# read returns (the file lock is held for the whole blocking read).
|
|
if self._resp is not None:
|
|
sock = getattr(self._resp.fp, "raw", None)
|
|
sock = getattr(sock, "_sock", None) if sock is not None else None
|
|
if sock is not None:
|
|
try:
|
|
sock.shutdown(socket.SHUT_RDWR)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self._resp.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def ws_client(adapter):
|
|
"""Connected + paired HTTP client; the adapter's server runs on an
|
|
ephemeral port for the duration of the test. Yields ``(client, ack)`` to
|
|
match the old WS fixture shape so test bodies need no signature change."""
|
|
await adapter.connect()
|
|
client = HttpTestClient(adapter._http_server.bound_port)
|
|
try:
|
|
ack = await client.start()
|
|
yield client, ack
|
|
finally:
|
|
await client.close()
|
|
await adapter.disconnect()
|
|
|
|
|
|
async def recv_until(ws, predicate, timeout: float = 10.0) -> list:
|
|
"""Collect frames (dicts) until *predicate* matches a frame. Returns all
|
|
frames collected."""
|
|
frames: list = []
|
|
loop = asyncio.get_running_loop()
|
|
deadline = loop.time() + timeout
|
|
while True:
|
|
remaining = deadline - loop.time()
|
|
if remaining <= 0:
|
|
raise AssertionError(
|
|
"timed out waiting for frame; got: "
|
|
+ ", ".join(f.get("type", "?") if isinstance(f, dict) else "?" for f in frames)
|
|
)
|
|
frame = await ws.recv(timeout=remaining)
|
|
frames.append(frame)
|
|
if predicate(frame):
|
|
return frames
|
|
|
|
|
|
async def upload_file(ws, media_ref: str, data: bytes, *, kind: str = "image",
|
|
mime: str = "image/png", filename: str = "t.png",
|
|
request_id: int = 1, sha256: str | None = None) -> dict:
|
|
"""Drive a media upload via the HTTP leg; returns the terminal frame
|
|
(ack or error)."""
|
|
return await ws.upload(
|
|
media_ref, data, kind=kind, mime=mime, filename=filename, sha256=sha256
|
|
)
|
|
|
|
|
|
# ── Lifecycle ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_broadcasts_status_restarting(adapter):
|
|
"""Teardown broadcasts ``status{state=restarting}`` before closing the
|
|
streams, so the app can distinguish a clean gateway teardown (restart/
|
|
stop) from a plain network drop — it shows the "Gateway restarting" chat
|
|
notice only when this frame was received (docs/04 §status)."""
|
|
await adapter.connect()
|
|
client = HttpTestClient(adapter._http_server.bound_port)
|
|
try:
|
|
await client.start()
|
|
await adapter.disconnect()
|
|
frames = []
|
|
while True:
|
|
try:
|
|
frames.append(await client.recv(timeout=5))
|
|
except (ConnectionError, asyncio.TimeoutError):
|
|
break
|
|
statuses = [f for f in frames if f.get("type") == "status"]
|
|
assert any(f["payload"]["state"] == "restarting" for f in statuses), (
|
|
f"expected status{{restarting}} before close, got: {frames}"
|
|
)
|
|
finally:
|
|
await client.close()
|
|
# Idempotent: the second call is a no-op on the already-stopped server.
|
|
await adapter.disconnect()
|
|
|
|
|
|
# ── Pure helpers ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_reclassify_kind_does_not_trust_client(plugin):
|
|
media = plugin.media
|
|
# Image magic wins over a lying "document" claim.
|
|
assert media.reclassify_kind(PNG_1X1, "document") == "image"
|
|
# Ogg audio claimed as document.
|
|
ogg = b"OggS" + b"\x00" * 16
|
|
assert media.reclassify_kind(ogg, "document") == "audio"
|
|
# Voice stays voice when the bytes are audio.
|
|
assert media.reclassify_kind(ogg, "voice") == "voice"
|
|
# m4a brand claimed as video -> audio.
|
|
m4a = b"\x00\x00\x00\x20ftypm4a " + b"\x00" * 16
|
|
assert media.reclassify_kind(m4a, "video") == "audio"
|
|
# Generic mp4 (isom) claimed as audio -> video.
|
|
mp4 = b"\x00\x00\x00\x20ftypisom" + b"\x00" * 16
|
|
assert media.reclassify_kind(mp4, "audio") == "video"
|
|
# No magic: keep declared video, else document.
|
|
assert media.reclassify_kind(b"hello world", "video") == "video"
|
|
assert media.reclassify_kind(b"hello world", "document") == "document"
|
|
|
|
|
|
def test_mime_and_ext_helpers(plugin):
|
|
media = plugin.media
|
|
assert media.mime_for_path("/x/a.PNG") == "image/png"
|
|
assert media.mime_for_path("/x/clip.MP4") == "video/mp4"
|
|
assert media.mime_for_path("/x/a.unknownext") == "application/octet-stream"
|
|
assert media.ext_for_mime("image/png", "x.jpg", ".jpg") == ".png"
|
|
assert media.ext_for_mime("application/pdf", "report.pdf", ".bin") == ".pdf"
|
|
assert media.ext_for_mime("application/x-unknown", "noext", ".bin") == ".bin"
|
|
|
|
|
|
# ── Runtime-metadata footer (app-controlled display) ────────────────────────
|
|
|
|
|
|
def test_runtime_footer_helper_omits_absent_fields(plugin):
|
|
pf = plugin.protocol.runtime_footer
|
|
# All absent -> empty dict (the app shows nothing).
|
|
assert pf() == {}
|
|
# Only fields with data are included.
|
|
d = pf(model="gpt-5.4", context_pct=38, cwd="~", latency=22.5, cost=0.0012)
|
|
assert d == {
|
|
"model": "gpt-5.4",
|
|
"context_pct": 38,
|
|
"cwd": "~",
|
|
"latency": 22.5,
|
|
"cost": 0.0012,
|
|
}
|
|
# context_pct clamped to 0-100.
|
|
assert pf(context_pct=150)["context_pct"] == 100
|
|
assert pf(context_pct=-5)["context_pct"] == 0
|
|
# cost omitted when 0 (local/free models); latency omitted when negative.
|
|
assert "cost" not in pf(cost=0.0)
|
|
assert "latency" not in pf(latency=-1.0)
|
|
|
|
|
|
def test_home_relative_cwd_collapses_home(plugin):
|
|
f = plugin.adapter._home_relative_cwd
|
|
home = os.path.expanduser("~")
|
|
assert f(home) == "~"
|
|
assert f(os.path.join(home, "proj", "src")) == "~/proj/src"
|
|
assert f("") == ""
|
|
# A path outside the home is returned absolute (unmodified).
|
|
assert f("/etc") == "/etc"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client, monkeypatch):
|
|
ws, _ = ws_client
|
|
# Simulate the post_api_request hook capturing the turn's model + tokens.
|
|
plugin.adapter._on_post_api_request(
|
|
platform="android",
|
|
model="openai/gpt-5.4",
|
|
usage={"prompt_tokens": 12345},
|
|
)
|
|
# Stub context-length resolution (avoid network probing in tests).
|
|
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
|
|
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~"))
|
|
res = await adapter.send("android:default", "hello", metadata={"notify": True})
|
|
assert res.success
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
|
msg = frames[-1]
|
|
runtime = msg["payload"].get("runtime")
|
|
assert runtime is not None
|
|
assert runtime["model"] == "gpt-5.4" # vendor prefix dropped
|
|
assert runtime["context_pct"] == round(12345 / 32768 * 100) # 38
|
|
assert runtime["cwd"] == "~"
|
|
assert "latency" in runtime and runtime["latency"] >= 0
|
|
# The turn buffer is drained: a second final send carries no stale model.
|
|
res2 = await adapter.send("android:default", "again", metadata={"notify": True})
|
|
assert res2.success
|
|
frames2 = await recv_until(
|
|
ws, lambda f: f.get("type") == "message" and f["payload"].get("text") == "again"
|
|
)
|
|
runtime2 = frames2[-1]["payload"].get("runtime")
|
|
# cwd is env-derived (always present); model/tokens are turn-scoped (gone).
|
|
assert runtime2 is not None
|
|
assert "model" not in runtime2
|
|
assert "context_pct" not in runtime2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client, monkeypatch):
|
|
ws, _ = ws_client
|
|
# A non-android turn must not pollute the android runtime buffer.
|
|
plugin.adapter._on_post_api_request(
|
|
platform="telegram",
|
|
model="openai/gpt-5.4",
|
|
usage={"prompt_tokens": 999},
|
|
)
|
|
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
|
|
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~"))
|
|
res = await adapter.send("android:default", "hi", metadata={"notify": True})
|
|
assert res.success
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
|
runtime = frames[-1]["payload"].get("runtime")
|
|
assert runtime is not None
|
|
assert "model" not in runtime # the telegram turn's model was not captured
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_history_preserves_runtime_footer(plugin, adapter, ws_client, monkeypatch):
|
|
ws, _ = ws_client
|
|
plugin.adapter._on_post_api_request(
|
|
platform="android",
|
|
model="openai/gpt-5.4",
|
|
usage={"prompt_tokens": 12345},
|
|
)
|
|
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
|
|
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~"))
|
|
await adapter.send("android:default", "hello", metadata={"notify": True})
|
|
# The outbox history reconstruction must carry the runtime object.
|
|
page = adapter._outbox.history("android:default", limit=50)
|
|
assert len(page["messages"]) == 1
|
|
m = page["messages"][0]
|
|
assert m.get("runtime", {}).get("model") == "gpt-5.4"
|
|
assert m["runtime"]["context_pct"] == round(12345 / 32768 * 100)
|
|
|
|
|
|
# ── Inbound upload ──────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_reassembles_verifies_and_caches(adapter, ws_client):
|
|
ws, ack = ws_client
|
|
assert ack["payload"]["server_caps"]["media"] is True
|
|
|
|
terminal = await upload_file(ws, "mu_t1", PNG_1X1)
|
|
assert terminal["type"] == "media.upload.ack", terminal
|
|
assert terminal["payload"]["ok"] is True
|
|
assert terminal["payload"]["media_ref"] == "mu_t1"
|
|
|
|
entry = adapter._media.get_inbound("mu_t1")
|
|
assert entry is not None
|
|
assert entry.kind == "image"
|
|
assert entry.mime == "image/png"
|
|
assert entry.size == len(PNG_1X1)
|
|
assert os.path.isfile(entry.path)
|
|
assert Path(entry.path).read_bytes() == PNG_1X1
|
|
# Cached under the hermes image cache root (profile-aware).
|
|
from hermes_constants import get_hermes_home
|
|
|
|
assert str(get_hermes_home() / "cache" / "images") in entry.path
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_declared_over_limit_rejected(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
limit = adapter.max_upload_bytes
|
|
# Over HTTP the server checks Content-Length before reading the body.
|
|
err = await upload_file(
|
|
ws, "mu_big", b"x" * (limit + 1), kind="document", mime="application/pdf",
|
|
filename="big.pdf",
|
|
)
|
|
assert err["type"] == "error"
|
|
assert err["payload"]["code"] == "media_too_large"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_over_limit_ref_not_consumed(adapter, ws_client):
|
|
"""Over HTTP the over-limit check happens before the body is read, so a
|
|
rejected upload must not consume its media_ref (a later valid upload with
|
|
the same ref succeeds)."""
|
|
ws, _ = ws_client
|
|
limit = adapter.max_upload_bytes
|
|
err = await upload_file(ws, "mu_reuse", b"x" * (limit + 1), kind="document")
|
|
assert err["type"] == "error"
|
|
assert err["payload"]["code"] == "media_too_large"
|
|
# The ref is free: a valid upload with the same ref now succeeds.
|
|
ok = await upload_file(ws, "mu_reuse", PNG_1X1)
|
|
assert ok["type"] == "media.upload.ack", ok
|
|
assert ok["payload"]["ok"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_sha256_mismatch_rejected(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
err = await upload_file(ws, "mu_bad", PNG_1X1, sha256="0" * 64)
|
|
assert err["type"] == "error"
|
|
assert err["payload"]["code"] == "internal"
|
|
assert adapter._media.get_inbound("mu_bad") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_duplicate_ref_rejected(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
first = await upload_file(ws, "mu_dup", PNG_1X1)
|
|
assert first["type"] == "media.upload.ack"
|
|
second = await upload_file(ws, "mu_dup", PNG_1X1, request_id=10)
|
|
assert second["type"] == "error"
|
|
assert second["payload"]["code"] == "unsupported"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_non_image_bytes_reclassified_not_cached_as_image(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
# Lies about being a PNG: the magic-byte re-sniff must keep it out of the
|
|
# image cache (it lands as a document instead).
|
|
payload = b"<html>not an image</html>"
|
|
terminal = await upload_file(
|
|
ws, "mu_liar", payload, kind="image", mime="image/png", filename="liar.html"
|
|
)
|
|
assert terminal["type"] == "media.upload.ack", terminal
|
|
entry = adapter._media.get_inbound("mu_liar")
|
|
assert entry is not None
|
|
assert entry.kind == "document"
|
|
from hermes_constants import get_hermes_home
|
|
|
|
assert str(get_hermes_home() / "cache" / "images") not in entry.path
|
|
|
|
|
|
# ── Inbound message.send with media_refs ────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_send_with_media_refs(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
from gateway.platforms.base import MessageType
|
|
|
|
terminal = await upload_file(ws, "mu_msg", PNG_1X1)
|
|
assert terminal["type"] == "media.upload.ack"
|
|
|
|
captured: list = []
|
|
adapter.handle_message = AsyncMock(side_effect=lambda event: captured.append(event))
|
|
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 5,
|
|
"type": "message.send",
|
|
"payload": {"text": "look at this", "media_refs": ["mu_msg"]},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(
|
|
ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user"
|
|
)
|
|
echo = frames[-1]
|
|
media = echo["payload"]["media"]
|
|
assert len(media) == 1
|
|
assert media[0]["media_id"] == "mu_msg"
|
|
assert media[0]["kind"] == "image"
|
|
assert media[0]["mime"] == "image/png"
|
|
assert media[0]["size"] == len(PNG_1X1)
|
|
|
|
assert len(captured) == 1
|
|
event = captured[0]
|
|
assert event.message_type == MessageType.PHOTO
|
|
assert len(event.media_urls) == 1
|
|
assert os.path.isfile(event.media_urls[0])
|
|
assert event.media_types == ["image/png"]
|
|
|
|
# Refs are single-use: a second send with the same ref is rejected.
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 6,
|
|
"type": "message.send",
|
|
"payload": {"text": "again", "media_refs": ["mu_msg"]},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 6)
|
|
assert frames[-1]["payload"]["code"] == "unsupported"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_send_media_only_allowed(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
terminal = await upload_file(ws, "mu_only", PNG_1X1)
|
|
assert terminal["type"] == "media.upload.ack"
|
|
captured: list = []
|
|
adapter.handle_message = AsyncMock(side_effect=lambda event: captured.append(event))
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 7,
|
|
"type": "message.send",
|
|
"payload": {"text": "", "media_refs": ["mu_only"]},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(
|
|
ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user"
|
|
)
|
|
assert frames[-1]["payload"]["text"] == ""
|
|
assert len(captured) == 1
|
|
assert captured[0].message_type.name == "PHOTO"
|
|
|
|
|
|
# ── Auto-threading (message.send auto_thread, docs/06 §6.3) ────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_send_auto_thread_creates_named_thread(adapter, ws_client, monkeypatch):
|
|
"""auto_thread in a channel's flat lane mints a fresh thread named from
|
|
the user's opening message (derived title); the echo and the agent event
|
|
carry the new thread_id."""
|
|
ws, _ = ws_client
|
|
import agent.title_generator as tg
|
|
|
|
monkeypatch.setattr(tg, "generate_title", lambda text, **kw: None)
|
|
captured: list = []
|
|
adapter.handle_message = AsyncMock(side_effect=lambda event: captured.append(event))
|
|
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 40,
|
|
"type": "message.send",
|
|
"chat_id": "android:default",
|
|
"payload": {"text": "fix the login bug please", "auto_thread": True},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(
|
|
ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user"
|
|
)
|
|
created = next(f for f in frames if f.get("type") == "channel.created")
|
|
assert created["payload"]["kind"] == "thread"
|
|
assert created["payload"]["auto"] is True
|
|
assert created["payload"]["parent_chat_id"] == "android:default"
|
|
assert created["payload"]["name"] == "fix the login bug please"
|
|
thread_id = created["payload"]["chat_id"]
|
|
|
|
echo = frames[-1]
|
|
assert echo["chat_id"] == "android:default"
|
|
assert echo["thread_id"] == thread_id
|
|
|
|
assert len(captured) == 1
|
|
assert captured[0].source.chat_id == "android:default"
|
|
assert captured[0].source.thread_id == thread_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_send_auto_thread_llm_upgrade_renames(adapter, ws_client, monkeypatch):
|
|
"""Stage 2 of the two-stage titling: the background LLM call renames the
|
|
auto-created thread and broadcasts channel.renamed."""
|
|
ws, _ = ws_client
|
|
import agent.title_generator as tg
|
|
|
|
monkeypatch.setattr(tg, "generate_title", lambda text, **kw: "Fix login bug")
|
|
adapter.handle_message = AsyncMock()
|
|
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 41,
|
|
"type": "message.send",
|
|
"chat_id": "android:default",
|
|
"payload": {"text": "fix the login bug please", "auto_thread": True},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "channel.renamed")
|
|
renamed = frames[-1]
|
|
assert renamed["payload"]["kind"] == "thread"
|
|
assert renamed["payload"]["name"] == "Fix login bug"
|
|
assert "auto" not in renamed["payload"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_send_auto_thread_ignored_with_existing_thread(adapter, ws_client, monkeypatch):
|
|
"""auto_thread is a no-op when the message already targets a thread."""
|
|
ws, _ = ws_client
|
|
import agent.title_generator as tg
|
|
|
|
monkeypatch.setattr(tg, "generate_title", lambda text, **kw: None)
|
|
adapter.handle_message = AsyncMock()
|
|
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 42,
|
|
"type": "message.send",
|
|
"chat_id": "android:default",
|
|
"thread_id": "t_9",
|
|
"payload": {"text": "follow up", "auto_thread": True},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(
|
|
ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user"
|
|
)
|
|
assert not any(f.get("type") == "channel.created" for f in frames)
|
|
assert frames[-1]["thread_id"] == "t_9"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_send_auto_thread_ignored_for_slash_command(adapter, ws_client, monkeypatch):
|
|
"""Slash commands are session-scoped, not conversation starters: no
|
|
thread is minted for them."""
|
|
ws, _ = ws_client
|
|
import agent.title_generator as tg
|
|
|
|
monkeypatch.setattr(tg, "generate_title", lambda text, **kw: None)
|
|
adapter.handle_message = AsyncMock()
|
|
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 43,
|
|
"type": "message.send",
|
|
"chat_id": "android:default",
|
|
"payload": {"text": "/new", "auto_thread": True},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(
|
|
ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user"
|
|
)
|
|
assert not any(f.get("type") == "channel.created" for f in frames)
|
|
assert frames[-1].get("thread_id") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_send_auto_thread_media_only_stays_flat(adapter, ws_client, monkeypatch):
|
|
"""Media-only sends have no text to title from: they stay in the flat
|
|
lane (no nameless threads)."""
|
|
ws, _ = ws_client
|
|
import agent.title_generator as tg
|
|
|
|
monkeypatch.setattr(tg, "generate_title", lambda text, **kw: None)
|
|
terminal = await upload_file(ws, "mu_flat", PNG_1X1)
|
|
assert terminal["type"] == "media.upload.ack"
|
|
adapter.handle_message = AsyncMock()
|
|
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 44,
|
|
"type": "message.send",
|
|
"chat_id": "android:default",
|
|
"payload": {"text": "", "media_refs": ["mu_flat"], "auto_thread": True},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(
|
|
ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user"
|
|
)
|
|
assert not any(f.get("type") == "channel.created" for f in frames)
|
|
assert frames[-1].get("thread_id") is None
|
|
|
|
|
|
# ── History / outbox persistence (restart restore) ─────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_echo_parked_in_outbox(adapter, ws_client):
|
|
"""The user-message echo must be appended to the outbox (not just
|
|
broadcast) so it survives a process death and is restored by sync /
|
|
history. Regression: echoes were a bare broadcast, so user messages
|
|
vanished on reconnect while bot messages survived."""
|
|
ws, _ = ws_client
|
|
adapter.handle_message = AsyncMock()
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 20,
|
|
"type": "message.send",
|
|
"chat_id": "android:default",
|
|
"payload": {"text": "persist me"},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(
|
|
ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user"
|
|
)
|
|
echo = frames[-1]
|
|
message_id = echo["payload"]["message_id"]
|
|
# The echo is parked in the outbox under the home channel.
|
|
page = adapter._outbox.history("android:default", limit=50)
|
|
ids = [m["message_id"] for m in page["messages"]]
|
|
assert message_id in ids
|
|
parked = next(m for m in page["messages"] if m["message_id"] == message_id)
|
|
assert parked["role"] == "user"
|
|
assert parked["text"] == "persist me"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_history_returns_final_messages_oldest_first(plugin, adapter, ws_client):
|
|
"""A ``history`` request reconstructs the final messages for a chat
|
|
(user echoes + assistant finals, streaming or not) in chronological
|
|
order, skipping intermediate streaming/tool frames."""
|
|
ws, _ = ws_client
|
|
protocol = plugin.protocol
|
|
adapter.handle_message = AsyncMock()
|
|
chat_id = "android:default"
|
|
# Two user turns, each with a (non-streaming) assistant final.
|
|
for i, text in enumerate(["one", "two"]):
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 30 + i,
|
|
"type": "message.send",
|
|
"chat_id": chat_id,
|
|
"payload": {"text": text},
|
|
}
|
|
)
|
|
)
|
|
await recv_until(
|
|
ws,
|
|
lambda f, t=text: f.get("type") == "message"
|
|
and f.get("payload", {}).get("role") == "user"
|
|
and f.get("payload", {}).get("text") == t,
|
|
)
|
|
# Park a final assistant message for the turn.
|
|
await adapter._broadcast_or_log(
|
|
chat_id,
|
|
protocol.message(
|
|
chat_id=chat_id,
|
|
message_id=f"asst_{i}",
|
|
role=protocol.ROLE_ASSISTANT,
|
|
text=f"reply {text}",
|
|
ts=1_000_000_000_000 + i,
|
|
),
|
|
)
|
|
page = adapter._outbox.history(chat_id, limit=50)
|
|
roles = [m["role"] for m in page["messages"]]
|
|
texts = [m["text"] for m in page["messages"]]
|
|
# Chronological: user one, asst one, user two, asst two.
|
|
assert roles == ["user", "assistant", "user", "assistant"]
|
|
assert texts == ["one", "reply one", "two", "reply two"]
|
|
assert page["has_more"] is False
|
|
assert page["oldest_message_id"] is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_history_paginates_older_pages(plugin, adapter, ws_client):
|
|
"""``before_message_id`` returns the page of messages older than the
|
|
anchor; ``has_more`` signals whether older pages exist."""
|
|
ws, _ = ws_client
|
|
protocol = plugin.protocol
|
|
adapter.handle_message = AsyncMock()
|
|
chat_id = "android:default"
|
|
for i in range(5):
|
|
await adapter._broadcast_or_log(
|
|
chat_id,
|
|
protocol.message(
|
|
chat_id=chat_id,
|
|
message_id=f"m_{i}",
|
|
role=protocol.ROLE_USER if i % 2 == 0 else protocol.ROLE_ASSISTANT,
|
|
text=f"msg {i}",
|
|
ts=1_000_000_000_000 + i,
|
|
),
|
|
)
|
|
newest = adapter._outbox.history(chat_id, limit=2)
|
|
assert [m["message_id"] for m in newest["messages"]] == ["m_3", "m_4"]
|
|
assert newest["has_more"] is True
|
|
older = adapter._outbox.history(chat_id, before_message_id=newest["oldest_message_id"], limit=2)
|
|
assert [m["message_id"] for m in older["messages"]] == ["m_1", "m_2"]
|
|
assert older["has_more"] is True
|
|
oldest = adapter._outbox.history(chat_id, before_message_id=older["oldest_message_id"], limit=2)
|
|
assert [m["message_id"] for m in oldest["messages"]] == ["m_0"]
|
|
assert oldest["has_more"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_history_frame_roundtrip(plugin, adapter, ws_client):
|
|
"""The ``history`` request/response frame round-trips over the wire: the
|
|
server answers a ``history`` request with a paged ``history`` frame."""
|
|
ws, _ = ws_client
|
|
protocol = plugin.protocol
|
|
adapter.handle_message = AsyncMock()
|
|
chat_id = "android:default"
|
|
await adapter._broadcast_or_log(
|
|
chat_id,
|
|
protocol.message(
|
|
chat_id=chat_id,
|
|
message_id="wire_1",
|
|
role=protocol.ROLE_USER,
|
|
text="over the wire",
|
|
ts=1_000_000_000_000,
|
|
),
|
|
)
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 40,
|
|
"type": "history",
|
|
"chat_id": chat_id,
|
|
"payload": {"limit": 10},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "history" and f.get("id") == 40)
|
|
resp = frames[-1]
|
|
assert resp["chat_id"] == chat_id
|
|
messages = resp["payload"]["messages"]
|
|
assert any(m["message_id"] == "wire_1" for m in messages)
|
|
assert "has_more" in resp["payload"]
|
|
|
|
|
|
# ── Message deletion ────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_delete_removes_from_outbox_and_broadcasts(plugin, adapter, ws_client):
|
|
"""A ``message.delete`` request removes the message from the outbox (so
|
|
``history`` no longer returns it) and broadcasts ``message.deleted`` to
|
|
every device (the response carries the request id)."""
|
|
ws, _ = ws_client
|
|
protocol = plugin.protocol
|
|
adapter.handle_message = AsyncMock()
|
|
chat_id = "android:default"
|
|
await adapter._broadcast_or_log(
|
|
chat_id,
|
|
protocol.message(
|
|
chat_id=chat_id,
|
|
message_id="del_1",
|
|
role=protocol.ROLE_USER,
|
|
text="delete me",
|
|
ts=1_000_000_000_000,
|
|
),
|
|
)
|
|
page = adapter._outbox.history(chat_id, limit=50)
|
|
assert any(m["message_id"] == "del_1" for m in page["messages"])
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 50,
|
|
"type": "message.delete",
|
|
"chat_id": chat_id,
|
|
"payload": {"message_ids": ["del_1"]},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "message.deleted" and f.get("id") == 50)
|
|
resp = frames[-1]
|
|
assert resp["chat_id"] == chat_id
|
|
assert resp["payload"]["message_ids"] == ["del_1"]
|
|
# The message is gone from the outbox: history no longer returns it.
|
|
page = adapter._outbox.history(chat_id, limit=50)
|
|
assert not any(m["message_id"] == "del_1" for m in page["messages"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_delete_idempotent(plugin, adapter, ws_client):
|
|
"""Deleting a message that is already gone (unknown / pruned) is a no-op
|
|
on the outbox but still emits ``message.deleted`` so live caches drop it."""
|
|
ws, _ = ws_client
|
|
adapter.handle_message = AsyncMock()
|
|
chat_id = "android:default"
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 51,
|
|
"type": "message.delete",
|
|
"chat_id": chat_id,
|
|
"payload": {"message_ids": ["never_existed"]},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "message.deleted" and f.get("id") == 51)
|
|
assert frames[-1]["payload"]["message_ids"] == ["never_existed"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_delete_requires_message_ids(adapter, ws_client):
|
|
"""A ``message.delete`` with no message_ids is rejected with ``unsupported``."""
|
|
ws, _ = ws_client
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 52,
|
|
"type": "message.delete",
|
|
"chat_id": "android:default",
|
|
"payload": {},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 52)
|
|
assert frames[-1]["payload"]["code"] == "unsupported"
|
|
|
|
|
|
def _make_state_db(path: Path) -> None:
|
|
"""Create a minimal hermes session store (sessions + messages) at *path*."""
|
|
import sqlite3
|
|
|
|
conn = sqlite3.connect(str(path))
|
|
try:
|
|
conn.execute(
|
|
"CREATE TABLE sessions (id TEXT PRIMARY KEY, chat_id TEXT, thread_id TEXT)"
|
|
)
|
|
conn.execute(
|
|
"CREATE TABLE messages ("
|
|
"id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, "
|
|
"role TEXT, content TEXT, timestamp REAL)"
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_delete_purges_session_store(plugin, adapter, ws_client):
|
|
"""A ``message.delete`` also removes the message from the hermes session
|
|
store (state.db), so no search trace survives and it is not recoverable."""
|
|
ws, _ = ws_client
|
|
protocol = plugin.protocol
|
|
adapter.handle_message = AsyncMock()
|
|
from hermes_constants import get_hermes_home
|
|
|
|
chat_id = "android:default"
|
|
db = get_hermes_home() / "state.db"
|
|
_make_state_db(db)
|
|
import sqlite3
|
|
|
|
conn = sqlite3.connect(str(db))
|
|
try:
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, chat_id, thread_id) VALUES ('s1', ?, NULL)",
|
|
(chat_id,),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, timestamp) "
|
|
"VALUES ('s1', 'user', 'delete me', 1000.0)"
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
# The outbox frame carries the same text + ts so the session row matches.
|
|
await adapter._broadcast_or_log(
|
|
chat_id,
|
|
protocol.message(
|
|
chat_id=chat_id,
|
|
message_id="purge_1",
|
|
role=protocol.ROLE_USER,
|
|
text="delete me",
|
|
ts=1_000_000,
|
|
),
|
|
)
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 53,
|
|
"type": "message.delete",
|
|
"chat_id": chat_id,
|
|
"payload": {"message_ids": ["purge_1"]},
|
|
}
|
|
)
|
|
)
|
|
await recv_until(ws, lambda f: f.get("type") == "message.deleted" and f.get("id") == 53)
|
|
conn = sqlite3.connect(str(db))
|
|
try:
|
|
n = conn.execute("SELECT COUNT(*) FROM messages WHERE content = 'delete me'").fetchone()[0]
|
|
finally:
|
|
conn.close()
|
|
assert n == 0 # purged from the session store (no search trace)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_delete_wipes_session_store(plugin, adapter, ws_client):
|
|
"""A ``channel.delete`` wipes the lane's messages + session from the hermes
|
|
session store (state.db) as well as the outbox, so nothing is recoverable."""
|
|
ws, _ = ws_client
|
|
protocol = plugin.protocol
|
|
adapter.handle_message = AsyncMock()
|
|
from hermes_constants import get_hermes_home
|
|
|
|
# Create a channel via the wire.
|
|
await ws.send(json.dumps({"v": 1, "id": 60, "type": "channel.create", "payload": {"name": "Secret"}}))
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "channel.created" and f.get("id") == 60)
|
|
chat_id = frames[-1]["payload"]["chat_id"]
|
|
db = get_hermes_home() / "state.db"
|
|
_make_state_db(db)
|
|
import sqlite3
|
|
|
|
conn = sqlite3.connect(str(db))
|
|
try:
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, chat_id, thread_id) VALUES ('s9', ?, NULL)",
|
|
(chat_id,),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, timestamp) "
|
|
"VALUES ('s9', 'user', 'top secret', 2000.0)"
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
await adapter._broadcast_or_log(
|
|
chat_id,
|
|
protocol.message(
|
|
chat_id=chat_id,
|
|
message_id="chan_1",
|
|
role=protocol.ROLE_USER,
|
|
text="top secret",
|
|
ts=2_000_000,
|
|
),
|
|
)
|
|
await ws.send(
|
|
json.dumps({"v": 1, "id": 61, "type": "channel.delete", "chat_id": chat_id, "payload": {}})
|
|
)
|
|
await recv_until(ws, lambda f: f.get("type") == "notification" and f["payload"]["kind"] == "channel_deleted")
|
|
# Outbox is wiped for the channel.
|
|
page = adapter._outbox.history(chat_id, limit=50)
|
|
assert not any(m["message_id"] == "chan_1" for m in page["messages"])
|
|
# Session store is wiped: the message and its session are gone.
|
|
conn = sqlite3.connect(str(db))
|
|
try:
|
|
n_msgs = conn.execute("SELECT COUNT(*) FROM messages WHERE content = 'top secret'").fetchone()[0]
|
|
n_sess = conn.execute("SELECT COUNT(*) FROM sessions WHERE chat_id = ?", (chat_id,)).fetchone()[0]
|
|
finally:
|
|
conn.close()
|
|
assert n_msgs == 0
|
|
assert n_sess == 0
|
|
|
|
|
|
# ── Outbound offers + pull ──────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_video_emits_offer_with_message_association(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
from gateway.platforms.base import get_video_cache_dir
|
|
|
|
video = get_video_cache_dir() / "clip.mp4"
|
|
video.write_bytes(b"fake-video-bytes")
|
|
|
|
# A final message first, so the offer can associate with it.
|
|
res = await adapter.send("android:default", "here you go", metadata={"notify": True})
|
|
assert res.success
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
|
msg_id = frames[-1]["payload"]["message_id"]
|
|
|
|
res2 = await adapter.send_video("android:default", str(video))
|
|
assert res2.success
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
|
|
offer = frames[-1]["payload"]
|
|
assert offer["kind"] == "video"
|
|
assert offer["mime"] == "video/mp4"
|
|
assert offer["size"] == len(b"fake-video-bytes")
|
|
assert offer["filename"] == "clip.mp4"
|
|
assert offer["media_id"] == res2.message_id
|
|
assert offer["media_id"].startswith("md_")
|
|
assert offer["message_id"] == msg_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_document_uses_caller_filename(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
from gateway.platforms.base import get_document_cache_dir
|
|
|
|
doc = get_document_cache_dir() / "report.pdf"
|
|
doc.write_bytes(b"%PDF-1.4 fake")
|
|
res = await adapter.send_document(
|
|
"android:default", str(doc), file_name="My Report.pdf"
|
|
)
|
|
assert res.success
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
|
|
offer = frames[-1]["payload"]
|
|
assert offer["kind"] == "document"
|
|
assert offer["filename"] == "My Report.pdf"
|
|
assert offer["mime"] == "application/pdf"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_image_file_offers_image(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
from gateway.platforms.base import get_image_cache_dir
|
|
|
|
img = get_image_cache_dir() / "shot.png"
|
|
img.write_bytes(PNG_1X1)
|
|
res = await adapter.send_image_file("android:default", str(img))
|
|
assert res.success
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
|
|
offer = frames[-1]["payload"]
|
|
assert offer["kind"] == "image"
|
|
assert offer["mime"] == "image/png"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_media_rejects_denied_path(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
# /etc/passwd exists but is on hermes' delivery denylist.
|
|
res = await adapter.send_document("android:default", "/etc/passwd")
|
|
assert not res.success
|
|
# Nothing was offered (a failed offer is silent, like other platforms).
|
|
try:
|
|
raw = await asyncio.wait_for(ws.recv(), timeout=1.0)
|
|
frame = json.loads(raw) if isinstance(raw, str) else None
|
|
assert frame is None or frame.get("type") != "media.offer"
|
|
except asyncio.TimeoutError:
|
|
pass # silence is the expected outcome
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pull_serves_allowed_path(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
from gateway.platforms.base import get_image_cache_dir
|
|
|
|
img = get_image_cache_dir() / "pull_test.png"
|
|
img.write_bytes(PNG_1X1)
|
|
entry = adapter._media.register_outbound(
|
|
str(img), "image", "image/png", "pull_test.png", len(PNG_1X1)
|
|
)
|
|
|
|
status, body = await ws.pull(entry.media_id)
|
|
assert status == 200
|
|
assert body == PNG_1X1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pull_rejects_unknown_and_denied(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
# Unknown media_id.
|
|
status, body = await ws.pull("md_nope")
|
|
assert status == 404
|
|
assert json.loads(body)["payload"]["code"] == "not_found"
|
|
|
|
# Known id, but the path fails delivery validation (denylist).
|
|
entry = adapter._media.register_outbound(
|
|
"/etc/passwd", "document", "text/plain", "passwd", 100
|
|
)
|
|
status, body = await ws.pull(entry.media_id)
|
|
assert status == 404
|
|
assert json.loads(body)["payload"]["code"] == "not_found"
|
|
|
|
# Known id, file deleted since the offer.
|
|
from gateway.platforms.base import get_image_cache_dir
|
|
|
|
img = get_image_cache_dir() / "gone.png"
|
|
img.write_bytes(PNG_1X1)
|
|
entry2 = adapter._media.register_outbound(
|
|
str(img), "image", "image/png", "gone.png", len(PNG_1X1)
|
|
)
|
|
img.unlink()
|
|
status, body = await ws.pull(entry2.media_id)
|
|
assert status == 404
|
|
assert json.loads(body)["payload"]["code"] == "not_found"
|
|
|
|
|
|
# ── M5: push backends (pure) ───────────────────────────────────────────────
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, status=200, body="", json_data=None):
|
|
self.status_code = status
|
|
self.text = body
|
|
self._json = json_data if json_data is not None else {}
|
|
|
|
def json(self):
|
|
return self._json
|
|
|
|
|
|
class _FakeHttpClient:
|
|
"""Stands in for ``httpx.AsyncClient`` (async CM + ``post``)."""
|
|
|
|
def __init__(self, responder):
|
|
self._responder = responder # (url, kwargs) -> _FakeResponse
|
|
self.calls = []
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
async def post(self, url, **kwargs):
|
|
self.calls.append((url, kwargs))
|
|
return self._responder(url, kwargs)
|
|
|
|
|
|
def _patch_httpx(plugin, monkeypatch, responder):
|
|
fake = _FakeHttpClient(responder)
|
|
monkeypatch.setattr(plugin.push.httpx, "AsyncClient", lambda *a, **k: fake)
|
|
return fake
|
|
|
|
|
|
def test_push_backend_selection(plugin):
|
|
push = plugin.push
|
|
assert isinstance(push.build_push_backend("fcm"), push.FcmBackend)
|
|
assert isinstance(push.build_push_backend("ntfy"), push.NtfyBackend)
|
|
assert isinstance(push.build_push_backend(None), push.FcmBackend) # default
|
|
assert isinstance(push.build_push_backend(" NTFY "), push.NtfyBackend)
|
|
|
|
assert push.build_push_backend("ntfy", ntfy_topic="my-topic").configured() is True
|
|
assert push.build_push_backend("ntfy").configured() is False
|
|
assert push.build_push_backend("fcm", fcm_server_key="key-123").configured() is True
|
|
assert push.build_push_backend("fcm").configured() is False
|
|
|
|
|
|
def test_push_backend_fcm_service_account_configured(plugin, tmp_path):
|
|
push = plugin.push
|
|
sa = tmp_path / "sa.json"
|
|
sa.write_text(
|
|
json.dumps(
|
|
{
|
|
"client_email": "x@y.iam.gserviceaccount.com",
|
|
"private_key": "k",
|
|
"project_id": "p",
|
|
}
|
|
)
|
|
)
|
|
assert (
|
|
push.build_push_backend("fcm", fcm_service_account=str(sa)).configured()
|
|
is True
|
|
)
|
|
assert (
|
|
push.build_push_backend(
|
|
"fcm", fcm_service_account=str(tmp_path / "missing.json")
|
|
).configured()
|
|
is False
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ntfy_backend_publishes_with_data_header(plugin, monkeypatch):
|
|
push = plugin.push
|
|
backend = push.NtfyBackend(topic="iris-topic", server_url="https://ntfy.example/")
|
|
fake = _patch_httpx(
|
|
plugin, monkeypatch, lambda url, kw: _FakeResponse(200, '{"id":"1"}')
|
|
)
|
|
ok = await backend.send(
|
|
device_id="d1",
|
|
chat_id="android:default",
|
|
title="Iris",
|
|
body="hello",
|
|
data={"chat_id": "android:default", "kind": "message", "cursor": "7"},
|
|
token="iris-topic",
|
|
)
|
|
assert ok is True
|
|
url, kw = fake.calls[0]
|
|
assert url == "https://ntfy.example/iris-topic"
|
|
headers = kw["headers"]
|
|
assert headers["X-Title"] == "Iris"
|
|
assert headers["X-Priority"] == "3"
|
|
assert json.loads(headers["X-Data"])["cursor"] == "7"
|
|
assert kw["content"] == b"hello"
|
|
|
|
fake2 = _patch_httpx(plugin, monkeypatch, lambda url, kw: _FakeResponse(200))
|
|
await backend.send(
|
|
device_id="d1", chat_id="c", title="t", body="b", data={},
|
|
token="t", priority="high",
|
|
)
|
|
assert fake2.calls[0][1]["headers"]["X-Priority"] == "5"
|
|
|
|
fake3 = _patch_httpx(plugin, monkeypatch, lambda url, kw: _FakeResponse(404, "nope"))
|
|
assert (
|
|
await backend.send(
|
|
device_id="d1", chat_id="c", title="t", body="b", data={}, token="t"
|
|
)
|
|
is False
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fcm_backend_legacy_server_key(plugin, monkeypatch):
|
|
push = plugin.push
|
|
backend = push.FcmBackend(server_key="legacy-key")
|
|
fake = _patch_httpx(
|
|
plugin, monkeypatch, lambda url, kw: _FakeResponse(200, '{"name":"m/1"}')
|
|
)
|
|
ok = await backend.send(
|
|
device_id="d1",
|
|
chat_id="android:default",
|
|
title="Iris",
|
|
body="hi",
|
|
data={"chat_id": "android:default", "kind": "message", "cursor": "3"},
|
|
token="fcm-token-1",
|
|
)
|
|
assert ok is True
|
|
url, kw = fake.calls[0]
|
|
assert url == push.FCM_LEGACY_SEND_URL
|
|
assert kw["headers"]["Authorization"] == "Bearer legacy-key"
|
|
assert kw["json"]["to"] == "fcm-token-1"
|
|
assert kw["json"]["notification"] == {"title": "Iris", "body": "hi"}
|
|
assert kw["json"]["data"]["cursor"] == "3"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fcm_backend_service_account_v1(plugin, monkeypatch, tmp_path):
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
pem = key.private_bytes(
|
|
serialization.Encoding.PEM,
|
|
serialization.PrivateFormat.PKCS8,
|
|
serialization.NoEncryption(),
|
|
).decode()
|
|
sa = tmp_path / "sa.json"
|
|
sa.write_text(
|
|
json.dumps(
|
|
{
|
|
"project_id": "iris-test-project",
|
|
"client_email": "svc@iris-test-project.iam.gserviceaccount.com",
|
|
"private_key_id": "kid-1",
|
|
"private_key": pem,
|
|
}
|
|
)
|
|
)
|
|
backend = plugin.push.FcmBackend(service_account=str(sa))
|
|
assert backend.configured() is True
|
|
|
|
def responder(url, kw):
|
|
if url == plugin.push.FCM_TOKEN_URL:
|
|
assert kw["data"]["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
|
return _FakeResponse(200, "", {"access_token": "at-123", "expires_in": 3600})
|
|
return _FakeResponse(200, '{"name":"projects/iris-test-project/messages/9"}')
|
|
|
|
fake = _patch_httpx(plugin, monkeypatch, responder)
|
|
ok = await backend.send(
|
|
device_id="d1",
|
|
chat_id="android:default",
|
|
title="Iris",
|
|
body="hi",
|
|
data={"chat_id": "android:default", "kind": "cron", "cursor": "4"},
|
|
token="fcm-token-2",
|
|
priority="high",
|
|
)
|
|
assert ok is True
|
|
token_url, token_kw = fake.calls[0]
|
|
send_url, send_kw = fake.calls[1]
|
|
assert token_url == plugin.push.FCM_TOKEN_URL
|
|
assert send_url == (
|
|
"https://fcm.googleapis.com/v1/projects/iris-test-project/messages:send"
|
|
)
|
|
assert send_kw["headers"]["Authorization"] == "Bearer at-123"
|
|
msg = send_kw["json"]["message"]
|
|
assert msg["token"] == "fcm-token-2"
|
|
assert msg["android"]["priority"] == "high"
|
|
assert msg["data"]["kind"] == "cron"
|
|
# The access token is cached: the next send does not re-exchange.
|
|
await backend.send(
|
|
device_id="d1", chat_id="c", title="t", body="b", data={}, token="fcm-token-2"
|
|
)
|
|
assert len(fake.calls) == 3
|
|
|
|
|
|
# ── M5: push firing (integration) ──────────────────────────────────────────
|
|
|
|
|
|
class _FakePush:
|
|
name = "fake"
|
|
token_field = "fcm_token"
|
|
|
|
def __init__(self, configured=True):
|
|
self.calls = []
|
|
self._configured = configured
|
|
self.fail_next = False
|
|
|
|
def configured(self):
|
|
return self._configured
|
|
|
|
async def send(self, **kwargs):
|
|
self.calls.append(kwargs)
|
|
if self.fail_next:
|
|
self.fail_next = False
|
|
return False
|
|
return True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_fires_when_no_live_subscriber(adapter):
|
|
fake = _FakePush()
|
|
adapter._push = fake
|
|
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
|
|
|
res = await adapter.send(
|
|
"android:default", "hello while offline", metadata={"notify": True}
|
|
)
|
|
assert res.success
|
|
assert len(fake.calls) == 1
|
|
call = fake.calls[0]
|
|
assert call["token"] == "tok-1"
|
|
assert call["chat_id"] == "android:default"
|
|
assert call["data"]["kind"] == "message"
|
|
assert call["data"]["chat_id"] == "android:default"
|
|
assert call["data"]["cursor"] == "1"
|
|
assert call["priority"] == "normal"
|
|
# The frame is parked in the outbox for sync.
|
|
assert adapter._outbox.latest_cursor() == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_not_fired_when_live(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
fake = _FakePush()
|
|
adapter._push = fake
|
|
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
|
|
|
await adapter.send("android:default", "live reply", metadata={"notify": True})
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
|
assert frames[-1]["payload"]["text"] == "live reply"
|
|
assert fake.calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_live_delivered_frame_still_parked_for_sync(adapter, ws_client):
|
|
"""A frame delivered to a live subscriber is still appended to the outbox,
|
|
so a reconnecting app (whose in-memory ChatStore was reset, e.g. after
|
|
tapping a push notification) can catch up via sync. Regression: chat empty
|
|
after tapping a message notification."""
|
|
ws, _ = ws_client
|
|
await adapter.send("android:default", "live reply", metadata={"notify": True})
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
|
assert frames[-1]["payload"]["text"] == "live reply"
|
|
# The live-delivered frame is still parked in the outbox for sync.
|
|
assert adapter._outbox.latest_cursor() == 1
|
|
rows = adapter._outbox.replay(0)
|
|
assert [r["frame"]["payload"]["text"] for r in rows] == ["live reply"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_echo_parked_for_sync(adapter, ws_client):
|
|
"""The user-message echo is appended to the outbox so a reconnecting app
|
|
(whose in-memory ChatStore was reset by a process death / activity
|
|
recreation) restores the user's own message via the sync replay.
|
|
Regression: user messages vanished after closing/multitasking while bot
|
|
messages (already parked) survived."""
|
|
ws, _ = ws_client
|
|
adapter.handle_message = AsyncMock()
|
|
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 90,
|
|
"type": "message.send",
|
|
"payload": {"text": "remember me"},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(
|
|
ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user"
|
|
)
|
|
echo = frames[-1]
|
|
assert echo["payload"]["text"] == "remember me"
|
|
|
|
# The echo is parked in the outbox for sync catch-up, carrying the same
|
|
# server-assigned id the live echo used (so the app dedupes on replay).
|
|
rows = adapter._outbox.replay(0)
|
|
user_rows = [
|
|
r
|
|
for r in rows
|
|
if r["frame"].get("type") == "message"
|
|
and r["frame"].get("payload", {}).get("role") == "user"
|
|
]
|
|
assert len(user_rows) == 1
|
|
assert user_rows[0]["frame"]["payload"]["text"] == "remember me"
|
|
assert user_rows[0]["frame"]["payload"]["message_id"] == echo["payload"]["message_id"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_intermediate_frames_park_without_push(adapter):
|
|
fake = _FakePush()
|
|
adapter._push = fake
|
|
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
|
|
|
await adapter.send("android:default", "seg", metadata={"expect_edits": True})
|
|
stream_id = adapter._turns["android:default"].stream_id
|
|
await adapter.edit_message("android:default", stream_id, "seg more")
|
|
assert adapter._outbox.latest_cursor() == 2
|
|
assert fake.calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_high_priority_notification_pushes_even_when_live(plugin, adapter, ws_client):
|
|
ws, _ = ws_client
|
|
fake = _FakePush()
|
|
adapter._push = fake
|
|
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
|
|
|
await adapter._broadcast_or_log(
|
|
"android:default",
|
|
plugin.protocol.notification(
|
|
"android:default", plugin.protocol.NOTIF_CRON, "Cron: Job", "body"
|
|
),
|
|
)
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "notification")
|
|
assert frames[-1]["payload"]["kind"] == "cron"
|
|
assert len(fake.calls) == 1
|
|
assert fake.calls[0]["priority"] == "high"
|
|
assert fake.calls[0]["data"]["kind"] == "cron"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_skipped_when_device_has_no_token(adapter):
|
|
fake = _FakePush()
|
|
adapter._push = fake
|
|
adapter._devices.upsert(DEVICE_ID, "Test", {}) # no push token
|
|
|
|
await adapter.send("android:default", "no token", metadata={"notify": True})
|
|
assert fake.calls == []
|
|
assert adapter._outbox.latest_cursor() == 1 # still parked for sync
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_skipped_when_backend_unconfigured(adapter):
|
|
fake = _FakePush(configured=False)
|
|
adapter._push = fake
|
|
adapter._devices.upsert(DEVICE_ID, "Test", {})
|
|
|
|
await adapter.send("android:default", "unconfigured", metadata={"notify": True})
|
|
assert fake.calls == []
|
|
|
|
|
|
# ── M5: fcm.register ───────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fcm_register_updates_registry(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"type": "fcm.register",
|
|
"payload": {"fcm_token": "rotated-token", "ntfy_topic": "dev-topic"},
|
|
}
|
|
)
|
|
)
|
|
# Ordering barrier: frames are processed in order, so by the time the
|
|
# channel.list response arrives, fcm.register has been handled.
|
|
await ws.send(
|
|
json.dumps({"v": 1, "id": 1, "type": "channel.list", "payload": {}})
|
|
)
|
|
await recv_until(ws, lambda f: f.get("type") == "channel.list")
|
|
dev = adapter._devices.get(DEVICE_ID)
|
|
assert dev["fcm_token"] == "rotated-token"
|
|
assert dev["ntfy_topic"] == "dev-topic"
|
|
|
|
# The next offline push targets the rotated token. Close the stream and
|
|
# force-remove the (possibly stale) subscriber so the push isn't counted
|
|
# as delivered to a dead connection.
|
|
await ws.close()
|
|
with adapter._http_server._subs_lock:
|
|
adapter._http_server._subs.clear()
|
|
fake = _FakePush()
|
|
adapter._push = fake
|
|
await adapter.send("android:default", "after rotation", metadata={"notify": True})
|
|
assert len(fake.calls) == 1
|
|
assert fake.calls[0]["token"] == "rotated-token"
|
|
|
|
|
|
# ── M5: notification banners ───────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_events_emit_notification_banners(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 1,
|
|
"type": "channel.create",
|
|
"payload": {"name": "Cron Reports"},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "notification")
|
|
notif = frames[-1]
|
|
assert notif["payload"]["kind"] == "channel_created"
|
|
assert "Cron Reports" in notif["payload"]["body"]
|
|
chat_id = notif["chat_id"]
|
|
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 2,
|
|
"type": "channel.rename",
|
|
"chat_id": chat_id,
|
|
"payload": {"name": "Reports"},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(
|
|
ws,
|
|
lambda f: f.get("type") == "notification"
|
|
and f["payload"]["kind"] == "channel_renamed",
|
|
)
|
|
assert frames[-1]["payload"]["body"] == "Renamed to Reports"
|
|
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 3,
|
|
"type": "channel.delete",
|
|
"chat_id": chat_id,
|
|
"payload": {},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(
|
|
ws,
|
|
lambda f: f.get("type") == "notification"
|
|
and f["payload"]["kind"] == "channel_deleted",
|
|
)
|
|
assert "Reports" in frames[-1]["payload"]["body"]
|
|
|
|
|
|
# ── M3: channel favorite / icon (cosmetic, synced) ─────────────────────────
|
|
|
|
|
|
async def _create_channel(ws, name: str, req_id: int) -> str:
|
|
await ws.send(
|
|
json.dumps({"v": 1, "id": req_id, "type": "channel.create", "payload": {"name": name}})
|
|
)
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "channel.created" and f.get("id") == req_id)
|
|
return frames[-1]["payload"]["chat_id"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_favorite_toggle(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
chat_id = await _create_channel(ws, "Work", 1)
|
|
# Fresh channel is not a favorite.
|
|
await ws.send(json.dumps({"v": 1, "id": 2, "type": "channel.list", "payload": {}}))
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "channel.list" and f.get("id") == 2)
|
|
entry = next(c for c in frames[-1]["payload"]["channels"] if c["chat_id"] == chat_id)
|
|
assert "favorite" not in entry
|
|
|
|
# Favorite it -> the renamed-shaped response carries favorite=true.
|
|
await ws.send(json.dumps({"v": 1, "id": 3, "type": "channel.favorite", "chat_id": chat_id, "payload": {"on": True}}))
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 3)
|
|
assert frames[-1]["payload"]["favorite"] is True
|
|
|
|
# Unfavorite it -> the flag is cleared (absent on the wire).
|
|
await ws.send(json.dumps({"v": 1, "id": 4, "type": "channel.favorite", "chat_id": chat_id, "payload": {"on": False}}))
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 4)
|
|
assert "favorite" not in frames[-1]["payload"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_favorite_unknown_id_rejected(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
await ws.send(json.dumps({"v": 1, "id": 1, "type": "channel.favorite", "chat_id": "android:chan_999", "payload": {"on": True}}))
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 1)
|
|
assert frames[-1]["payload"]["code"] == "not_found"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_icon_set_and_clear(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
chat_id = await _create_channel(ws, "Bugs", 1)
|
|
icon = "aGVsbG8=" # base64 of "hello"
|
|
await ws.send(json.dumps({"v": 1, "id": 2, "type": "channel.icon", "chat_id": chat_id, "payload": {"icon": icon, "color": "#FF0000"}}))
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 2)
|
|
assert frames[-1]["payload"]["icon"] == icon
|
|
assert frames[-1]["payload"]["color"] == "#FF0000"
|
|
|
|
# Clear both (empty payload) -> icon/color absent on the wire.
|
|
await ws.send(json.dumps({"v": 1, "id": 3, "type": "channel.icon", "chat_id": chat_id, "payload": {}}))
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 3)
|
|
assert "icon" not in frames[-1]["payload"]
|
|
assert "color" not in frames[-1]["payload"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_icon_too_large_rejected(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
chat_id = await _create_channel(ws, "Big", 1)
|
|
await ws.send(json.dumps({"v": 1, "id": 2, "type": "channel.icon", "chat_id": chat_id, "payload": {"icon": "x" * (512 * 1024 + 1)}}))
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 2)
|
|
assert frames[-1]["payload"]["code"] == "unsupported"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_list_orders_favorites_first(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
alpha = await _create_channel(ws, "Alpha", 1)
|
|
beta = await _create_channel(ws, "Beta", 2)
|
|
# Favorite the later-created channel; it must sort ahead of Alpha.
|
|
await ws.send(json.dumps({"v": 1, "id": 3, "type": "channel.favorite", "chat_id": beta, "payload": {"on": True}}))
|
|
await recv_until(ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 3)
|
|
await ws.send(json.dumps({"v": 1, "id": 4, "type": "channel.list", "payload": {}}))
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "channel.list" and f.get("id") == 4)
|
|
names = [c["name"] for c in frames[-1]["payload"]["channels"]]
|
|
assert names.index("Beta") < names.index("Alpha")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cron_delivery_emits_banner_and_message(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
wrapped = (
|
|
"Cronjob Response: My Job\n"
|
|
"(job_id: abc123)\n"
|
|
"-------------\n\n"
|
|
"hello from cron\n\n"
|
|
"To stop or manage this job, send me a new message (e.g. \"stop reminder My Job\")."
|
|
)
|
|
res = await adapter.send("android:default", wrapped, metadata={"job_id": "abc123"})
|
|
assert res.success
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
|
msg = frames[-1]
|
|
assert msg["payload"]["text"] == "hello from cron"
|
|
assert msg["payload"]["role"] == "assistant"
|
|
notif = [f for f in frames if f.get("type") == "notification"]
|
|
assert len(notif) == 1
|
|
assert notif[0]["payload"]["kind"] == "cron"
|
|
assert notif[0]["payload"]["title"] == "Cron: My Job"
|
|
assert notif[0]["payload"]["body"] == "hello from cron"
|
|
|
|
# wrap_response: false -- raw content, job id as the name.
|
|
res2 = await adapter.send("android:default", "raw cron output", metadata={"job_id": "j2"})
|
|
assert res2.success
|
|
frames = await recv_until(
|
|
ws,
|
|
lambda f: f.get("type") == "message"
|
|
and f["payload"].get("text") == "raw cron output",
|
|
)
|
|
notif2 = [f for f in frames if f.get("type") == "notification"]
|
|
assert notif2[0]["payload"]["title"] == "Cron: j2"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clarify_emits_banner_and_message(adapter, ws_client):
|
|
ws, _ = ws_client
|
|
from tools import clarify_gateway as cg
|
|
|
|
cg.register("cl_1", "sk", "Which one?", ["A", "B"])
|
|
try:
|
|
res = await adapter.send_clarify(
|
|
"android:default", "Which one?", ["A", "B"], "cl_1", "sk"
|
|
)
|
|
assert res.success
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
|
msg = frames[-1]
|
|
assert "❓ Which one?" in msg["payload"]["text"]
|
|
assert "1. A" in msg["payload"]["text"]
|
|
notif = [f for f in frames if f.get("type") == "notification"]
|
|
assert notif[0]["payload"]["kind"] == "clarify"
|
|
assert notif[0]["payload"]["body"] == "Which one?"
|
|
entry = cg._entries.get("cl_1")
|
|
assert entry is not None and entry.awaiting_text is True
|
|
finally:
|
|
cg._entries.pop("cl_1", None)
|
|
cg._session_index.pop("sk", None)
|
|
|
|
|
|
# ── M5: sync catch-up + outbox row cap ─────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_replays_parked_frames_and_done_cursor(adapter):
|
|
# Park two frames while offline.
|
|
await adapter.send("android:default", "one", metadata={"notify": True})
|
|
await adapter.send("android:default", "two", metadata={"notify": True})
|
|
assert adapter._outbox.latest_cursor() == 2
|
|
|
|
await adapter.connect()
|
|
# Open at the latest cursor so the SSE catch-up doesn't replay the parked
|
|
# frames (the sync request below is what we're testing).
|
|
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
|
|
try:
|
|
ack = await ws.start()
|
|
assert ack["payload"]["sync_cursor"] == 2
|
|
await ws.send(
|
|
json.dumps({"v": 1, "id": 10, "type": "sync", "payload": {"cursor": 0}})
|
|
)
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "sync.done")
|
|
texts = [f["payload"]["text"] for f in frames if f.get("type") == "message"]
|
|
assert texts == ["one", "two"]
|
|
done = frames[-1]
|
|
assert done["id"] == 10
|
|
assert done["payload"]["cursor"] == 2
|
|
# A sync at the current cursor replays nothing.
|
|
await ws.send(
|
|
json.dumps({"v": 1, "id": 11, "type": "sync", "payload": {"cursor": 2}})
|
|
)
|
|
frames = await recv_until(
|
|
ws, lambda f: f.get("type") == "sync.done" and f.get("id") == 11
|
|
)
|
|
assert len(frames) == 1
|
|
finally:
|
|
await ws.close()
|
|
await adapter.disconnect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hello_ack_last_pushed_cursor_default_zero(adapter):
|
|
"""A device that never received a push reports last_pushed_cursor=0 in
|
|
hello.ack (docs/08 §8.7 dedupe watermark)."""
|
|
await adapter.connect()
|
|
ws = HttpTestClient(adapter._http_server.bound_port)
|
|
try:
|
|
ack = await ws.start()
|
|
assert ack["payload"]["last_pushed_cursor"] == 0
|
|
finally:
|
|
await ws.close()
|
|
await adapter.disconnect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_success_advances_last_pushed_cursor(adapter):
|
|
"""A successful push advances the device's last_pushed_cursor, and the
|
|
next hello.ack reports it — the app uses it to skip re-notifying
|
|
sync-replayed frames (docs/08 §8.7). Back-to-back frames for the same
|
|
chat coalesce into one push; a failed push does not advance the cursor."""
|
|
fake = _FakePush()
|
|
adapter._push = fake
|
|
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
|
|
|
await adapter.send("android:default", "one", metadata={"notify": True})
|
|
assert len(fake.calls) == 1
|
|
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 1
|
|
|
|
# Immediate second frame (cron's message frame) coalesces — no second
|
|
# push, cursor unchanged.
|
|
await adapter.send("android:default", "two", metadata={"notify": True})
|
|
assert len(fake.calls) == 1
|
|
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 1
|
|
|
|
# Simulate the coalesce window elapsing, then push again.
|
|
adapter._last_push_at["android:default"] = 0.0
|
|
await adapter.send("android:default", "three", metadata={"notify": True})
|
|
assert len(fake.calls) == 2
|
|
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
|
|
|
|
# A failed push must NOT advance the cursor (the device never woke).
|
|
fake.fail_next = True
|
|
adapter._last_push_at["android:default"] = 0.0
|
|
await adapter.send("android:default", "four", metadata={"notify": True})
|
|
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
|
|
|
|
await adapter.connect()
|
|
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
|
|
try:
|
|
ack = await ws.start()
|
|
assert ack["payload"]["last_pushed_cursor"] == 3
|
|
finally:
|
|
await ws.close()
|
|
await adapter.disconnect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_replay_frames_carry_outbox_cursor(adapter):
|
|
"""Frames replayed by sync carry their outbox cursor in the envelope so
|
|
the app can compare it against last_pushed_cursor (docs/08 §8.7). Live
|
|
frames carry no cursor."""
|
|
await adapter.send("android:default", "one", metadata={"notify": True})
|
|
await adapter.send("android:default", "two", metadata={"notify": True})
|
|
|
|
await adapter.connect()
|
|
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
|
|
try:
|
|
await ws.start()
|
|
await ws.send(
|
|
json.dumps({"v": 1, "id": 20, "type": "sync", "payload": {"cursor": 0}})
|
|
)
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "sync.done")
|
|
msgs = [f for f in frames if f.get("type") == "message"]
|
|
assert [f["cursor"] for f in msgs] == [1, 2]
|
|
# sync.done itself carries no envelope cursor.
|
|
assert "cursor" not in frames[-1]
|
|
finally:
|
|
await ws.close()
|
|
await adapter.disconnect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_live_frames_carry_no_cursor(adapter, ws_client):
|
|
"""Live (non-replay) frames must not carry a cursor — the app only
|
|
suppresses notifications for replayed frames (docs/08 §8.7)."""
|
|
ws, _ = ws_client
|
|
await adapter.send("android:default", "live", metadata={"notify": True})
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
|
assert "cursor" not in frames[-1]
|
|
|
|
|
|
def test_outbox_row_cap_prunes_oldest(plugin, tmp_path):
|
|
outbox = plugin.outbox.Outbox(tmp_path / "ob.db", max_rows=5)
|
|
try:
|
|
for i in range(7):
|
|
outbox.append(
|
|
"android:default",
|
|
json.dumps({"v": 1, "type": "message", "payload": {"n": i}}),
|
|
)
|
|
assert outbox.latest_cursor() == 7 # cursor stays monotonic
|
|
assert outbox.take_overflow_pruned() == 2
|
|
rows = outbox.replay(0)
|
|
assert [r["frame"]["payload"]["n"] for r in rows] == [2, 3, 4, 5, 6]
|
|
assert outbox.take_overflow_pruned() == 0
|
|
finally:
|
|
outbox.close()
|
|
|
|
|
|
def test_outbox_delete_message_removes_all_frames_for_id(plugin, tmp_path):
|
|
"""Outbox.delete_message removes every frame for a message_id (any type)
|
|
in the chat, leaving other messages intact; a thread_id scopes the delete."""
|
|
outbox = plugin.outbox.Outbox(tmp_path / "ob.db")
|
|
try:
|
|
chat = "android:default"
|
|
# A streaming message spans start/update/stop; a standalone message is
|
|
# one frame. Plus an unrelated message that must survive.
|
|
outbox.append(chat, json.dumps({"v": 1, "type": "message.start", "payload": {"message_id": "m1", "role": "assistant"}}))
|
|
outbox.append(chat, json.dumps({"v": 1, "type": "message.update", "payload": {"message_id": "m1", "text": "hi"}}))
|
|
outbox.append(chat, json.dumps({"v": 1, "type": "message.stop", "payload": {"message_id": "m1", "final_text": "hi"}}))
|
|
outbox.append(chat, json.dumps({"v": 1, "type": "message", "payload": {"message_id": "m2", "role": "user", "text": "keep"}}))
|
|
# A same-id message in a different thread must survive a flat-lane delete.
|
|
outbox.append(chat, json.dumps({"v": 1, "type": "message", "thread_id": "t_1", "payload": {"message_id": "m1", "role": "user", "text": "thread"}}))
|
|
removed = outbox.delete_message(chat, "m1")
|
|
assert removed == 3 # the three flat-lane frames; the t_1 one is scoped out
|
|
rows = outbox.replay(0)
|
|
ids = [r["frame"].get("payload", {}).get("message_id") for r in rows]
|
|
assert "m2" in ids
|
|
assert ids.count("m1") == 1 # only the t_1 frame remains
|
|
# Deleting it with the thread scope removes the last one.
|
|
assert outbox.delete_message(chat, "m1", thread_id="t_1") == 1
|
|
# Idempotent: deleting again removes nothing.
|
|
assert outbox.delete_message(chat, "m1") == 0
|
|
finally:
|
|
outbox.close()
|
|
|
|
|
|
def test_outbox_delete_lane_removes_channel_and_thread_frames(plugin, tmp_path):
|
|
"""Outbox.delete_lane wipes a whole lane: a channel delete (thread_id=None)
|
|
removes every frame for the chat (flat + all threads); a thread delete is
|
|
scoped to that thread's frames only."""
|
|
outbox = plugin.outbox.Outbox(tmp_path / "ob.db")
|
|
try:
|
|
chan = "android:chan_9"
|
|
# Flat-lane frames + two threads' frames, plus an unrelated channel.
|
|
outbox.append(chan, json.dumps({"v": 1, "type": "message", "payload": {"message_id": "a", "role": "user", "text": "flat"}}))
|
|
outbox.append(chan, json.dumps({"v": 1, "type": "message", "thread_id": "t_1", "payload": {"message_id": "b", "role": "user", "text": "t1"}}))
|
|
outbox.append(chan, json.dumps({"v": 1, "type": "message", "thread_id": "t_2", "payload": {"message_id": "c", "role": "user", "text": "t2"}}))
|
|
outbox.append("android:chan_8", json.dumps({"v": 1, "type": "message", "payload": {"message_id": "z", "role": "user", "text": "other"}}))
|
|
# Thread delete: only t_1's frame goes.
|
|
assert outbox.delete_lane(chan, thread_id="t_1") == 1
|
|
rows = outbox.replay(0)
|
|
mids = [r["frame"].get("payload", {}).get("message_id") for r in rows]
|
|
assert "b" not in mids and "a" in mids and "c" in mids and "z" in mids
|
|
# Channel delete: the rest of the channel (flat + t_2) goes, chan_8 stays.
|
|
assert outbox.delete_lane(chan) == 2
|
|
rows = outbox.replay(0)
|
|
mids = [r["frame"].get("payload", {}).get("message_id") for r in rows]
|
|
assert mids == ["z"]
|
|
finally:
|
|
outbox.close()
|
|
|
|
|
|
def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path):
|
|
"""ChannelDirectory.delete removes the row entirely (not archived) and, for
|
|
a channel, its threads; the default channel cannot be deleted."""
|
|
d = plugin.channels.ChannelDirectory(tmp_path / "ch.db")
|
|
try:
|
|
d.ensure_default("android:default", "Default")
|
|
chan = d.create("Work", kind="channel")
|
|
t1 = d.create("Topic", kind="thread", parent_chat_id=chan["chat_id"])
|
|
# Deleting the channel removes it AND its thread from the directory.
|
|
entry = d.delete(chan["chat_id"])
|
|
assert entry is not None and entry["chat_id"] == chan["chat_id"]
|
|
assert d.get(chan["chat_id"]) is None # hard-deleted, not archived
|
|
assert d.get(t1["chat_id"]) is None # child thread gone too
|
|
assert chan["chat_id"] not in [c["chat_id"] for c in d.list()]
|
|
# Deleting a thread removes just that thread.
|
|
chan2 = d.create("Ops", kind="channel")
|
|
t2 = d.create("Bug", kind="thread", parent_chat_id=chan2["chat_id"])
|
|
assert d.delete(t2["chat_id"]) is not None
|
|
assert d.get(t2["chat_id"]) is None
|
|
assert d.get(chan2["chat_id"]) is not None # parent channel survives
|
|
# The default channel cannot be deleted.
|
|
assert d.delete("android:default") is None
|
|
assert d.get("android:default") is not None
|
|
# Unknown id -> None.
|
|
assert d.delete("android:chan_nope") is None
|
|
finally:
|
|
d.close()
|
|
|
|
|
|
# ── Pairing sanity (M1 regression guard for the media-capable server) ──────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wrong_token_rejected(adapter):
|
|
"""A request with a wrong Bearer token is rejected with 401 (the HTTP
|
|
equivalent of the WS hello auth rejection)."""
|
|
await adapter.connect()
|
|
try:
|
|
port = adapter._http_server.bound_port
|
|
|
|
def _req() -> int:
|
|
conn = HTTPConnection("127.0.0.1", port, timeout=5)
|
|
conn.request(
|
|
"GET",
|
|
"/v1/events",
|
|
headers={"Authorization": "Bearer wrong-token", "X-Iris-Device": DEVICE_ID},
|
|
)
|
|
resp = conn.getresponse()
|
|
resp.read()
|
|
status = resp.status
|
|
conn.close()
|
|
return status
|
|
|
|
status = await asyncio.to_thread(_req)
|
|
assert status == 401
|
|
finally:
|
|
await adapter.disconnect()
|
|
|
|
|
|
# ── M2: tool-detail capture (verbose args + post_tool_call output) ─────────
|
|
|
|
|
|
def test_extract_verbose_args(plugin):
|
|
a = plugin.adapter
|
|
content = '🔍 web_search(["query"])\n{"query": "hermes agent"}'
|
|
line = '🔍 web_search(["query"])'
|
|
assert a._extract_verbose_args(line, content) == {"query": "hermes agent"}
|
|
# Non-verbose line (no "(keys)" header) -> None.
|
|
assert a._extract_verbose_args('🔍 web_search: "x"', content) is None
|
|
# Header with no JSON body on the following line -> None.
|
|
assert a._extract_verbose_args(line, line) is None
|
|
|
|
|
|
def test_short_preview_from_args(plugin):
|
|
a = plugin.adapter
|
|
assert a._short_preview_from_args({"query": "hermes agent"}) == "hermes agent"
|
|
# First non-empty string value wins (skips non-strings).
|
|
assert a._short_preview_from_args({"n": 5, "path": "/tmp/x"}) == "/tmp/x"
|
|
assert a._short_preview_from_args({}) is None
|
|
prev = a._short_preview_from_args({"cmd": "a" * 100})
|
|
assert prev is not None and prev.endswith("…") and len(prev) <= 60
|
|
|
|
|
|
def test_tool_end_fields_from_hook(plugin):
|
|
a = plugin.adapter
|
|
a._reset_tool_results()
|
|
try:
|
|
# No captured record yet -> empty (caller keeps ok=True default).
|
|
assert a._tool_end_fields("terminal") == {}
|
|
# Capture a completed call, then consume it.
|
|
a._on_post_tool_call(
|
|
tool_name="terminal",
|
|
result='{"ok": true, "stdout": "hello"}',
|
|
duration_ms=1234,
|
|
status="ok",
|
|
)
|
|
fields = a._tool_end_fields("terminal")
|
|
assert fields["ok"] is True
|
|
assert fields["output_preview"] == '{"ok": true, "stdout": "hello"}'
|
|
assert fields["duration"] == 1.234
|
|
# Consumed -> a second lookup is empty.
|
|
assert a._tool_end_fields("terminal") == {}
|
|
# Error status -> ok False; zero duration omitted.
|
|
a._on_post_tool_call(tool_name="terminal", result="boom", duration_ms=0, status="error")
|
|
f2 = a._tool_end_fields("terminal")
|
|
assert f2["ok"] is False and "duration" not in f2
|
|
finally:
|
|
a._reset_tool_results()
|
|
|
|
|
|
def test_parse_tool_line_or_block_verbose(plugin):
|
|
a = plugin.adapter
|
|
content = '🔍 web_search(["query"])\n{"query": "hermes agent"}'
|
|
name, preview, args = a.AndroidAdapter._parse_tool_line_or_block(
|
|
'🔍 web_search(["query"])', content
|
|
)
|
|
assert name == "web_search"
|
|
assert args == {"query": "hermes agent"}
|
|
assert preview == "hermes agent" # derived short preview
|
|
# Non-verbose line -> args None, preview from the line.
|
|
name2, preview2, args2 = a.AndroidAdapter._parse_tool_line_or_block(
|
|
'🔍 web_search: "x"', '🔍 web_search: "x"'
|
|
)
|
|
assert name2 == "web_search" and preview2 == "x" and args2 is None
|
|
|
|
|
|
# ── Slash-command catalog (the app's "/" drawer) ──────────────────────────
|
|
|
|
|
|
def test_slash_catalog_shape_and_gateway_filtering(plugin):
|
|
"""The catalog is the gateway-available COMMAND_REGISTRY subset: every
|
|
entry carries the wire shape, cli_only commands are excluded, aliases
|
|
keep the leading slash."""
|
|
a = plugin.adapter
|
|
catalog = a._slash_command_catalog()
|
|
assert catalog, "expected a non-empty slash catalog"
|
|
names = {e["name"] for e in catalog}
|
|
# A core gateway command is present with its alias.
|
|
assert "/new" in names
|
|
new = next(e for e in catalog if e["name"] == "/new")
|
|
assert "/reset" in new["aliases"]
|
|
# A cli_only command is not surfaced to the gateway platform.
|
|
assert "/clear" not in names
|
|
for e in catalog:
|
|
assert e["name"].startswith("/")
|
|
assert isinstance(e["description"], str) and e["description"]
|
|
assert isinstance(e["args_hint"], str)
|
|
assert isinstance(e["category"], str) and e["category"]
|
|
assert all(alias.startswith("/") for alias in e["aliases"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commands_catalog_frame_roundtrip(plugin, adapter, ws_client):
|
|
"""A ``commands.catalog`` request is answered with the catalog on the
|
|
same id; the response carries the wire shape the app's drawer consumes."""
|
|
ws, _ = ws_client
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"id": 42,
|
|
"type": "commands.catalog",
|
|
"payload": {},
|
|
}
|
|
)
|
|
)
|
|
frames = await recv_until(ws, lambda f: f.get("type") == "commands.catalog")
|
|
resp = frames[-1]
|
|
assert resp["id"] == 42
|
|
commands = resp["payload"]["commands"]
|
|
assert commands, "expected a non-empty catalog"
|
|
names = {c["name"] for c in commands}
|
|
assert "/new" in names
|
|
assert "/clear" not in names # cli_only
|
|
for c in commands:
|
|
assert c["name"].startswith("/")
|
|
assert "description" in c and "args_hint" in c and "category" in c
|
|
assert isinstance(c["aliases"], list) |