Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
CI / Kotlin tests (android host + desktop) (push) Successful in 8m5s
CI / Gateway plugin tests (push) Successful in 9m47s

This commit is contained in:
ARIA committed 2026-08-22 22:43:13 +02:00
1 parent 27dc7917f2
commit 7a6d922d12
63 files changed
+2073 -630

No files matched your search

+4 -4
View File
@@ -1,4 +1,4 @@
# Tests for the android gateway plugin.
# Tests for the iris gateway plugin.
Run via hermes's hermetic runner (never bare pytest)::
@@ -13,7 +13,7 @@ drives a turn, printing every frame. Run with the hermes venv python
(needs `websockets`); the gateway must already be up::
hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py \
--token <ANDROID_TOKEN> --send "hello"
--token <IRIS_TOKEN> --send "hello"
Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
`--fcm-token`/`--fcm-reg`, `--authfail`, `--url`, `--token`, `--device`,
@@ -48,7 +48,7 @@ Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
`POST /v1/frame` (the `message.send`), receive over SSE `GET
/v1/events`. The same assertion flags apply. The base URL defaults to
the `--url` host with scheme `ws(s)` → `http(s)` and port 8791
(`ANDROID_HTTP_PORT`).
(`IRIS_HTTP_PORT`).
Exit codes: `0` ok (incl. SKIP for absent M7 frames), `2` connect fail,
`3` no hello.ack, `4` expected hello.ack, `5` authfail expected but
@@ -72,7 +72,7 @@ FAIL per scenario plus a summary table; exits 0 if no FAIL, 1 otherwise::
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
The token is read from `$ANDROID_TOKEN`, else `hermes-agent/.env`, else
The token is read from `$IRIS_TOKEN`, else `hermes-agent/.env`, else
`~/.hermes/.env`. The gateway must already be running (the driver never
starts or stops it). It is idempotent: channels/jobs it creates are
cleaned up even on failure, and leftover `e2e-*` channels/jobs from
+8 -8
View File
@@ -11,7 +11,7 @@ Usage::
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
The token is read from $ANDROID_TOKEN, else hermes-agent/.env, else
The token is read from $IRIS_TOKEN, else hermes-agent/.env, else
~/.hermes/.env. The gateway must already be running (this driver never
starts or stops it). Idempotent: channels/jobs it creates are cleaned up
even on failure, and leftover "e2e-*" channels/jobs from earlier runs are
@@ -52,14 +52,14 @@ PASS, PARTIAL, SKIP, FAIL = "PASS", "PARTIAL", "SKIP", "FAIL"
def find_token(cli_token: str) -> str:
if cli_token:
return cli_token
env = os.getenv("ANDROID_TOKEN")
env = os.getenv("IRIS_TOKEN")
if env:
return env
for p in (REPO / "hermes-agent" / ".env", Path.home() / ".hermes" / ".env"):
try:
for raw_line in p.read_text().splitlines():
line = raw_line.strip()
if line.startswith("ANDROID_TOKEN="):
if line.startswith("IRIS_TOKEN="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
pass
@@ -204,7 +204,7 @@ def s7_cron(env, url, token):
if not chat_id:
return FAIL, "channel.created received but chat_id not parseable"
job_name = f"e2e-cron-{uuid.uuid4().hex[:6]}"
deliver = f"android:{chat_id}"
deliver = f"iris:{chat_id}"
rc, out, err = run_hermes(
env, "cron", "create", "1m",
"Reply with exactly: e2e cron delivery OK",
@@ -310,7 +310,7 @@ def s13_http_fallback(env, url, token):
must land on the SSE stream promptly after the POST (< 1.5 s on LAN)."""
u = urlparse(url)
scheme = "https" if u.scheme == "wss" else "http"
http_port = os.getenv("ANDROID_HTTP_PORT", "8791")
http_port = os.getenv("IRIS_HTTP_PORT", "8791")
http_url = f"{scheme}://{u.hostname or '127.0.0.1'}:{http_port}"
rc, out, _ = run_probe(env, url, token, "--http", "--http-url", http_url,
"--send", "Reply with exactly: e2e http fallback OK",
@@ -352,7 +352,7 @@ def main() -> int:
p = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
p.add_argument("--url", default=os.getenv("ANDROID_WS_URL", DEFAULT_URL))
p.add_argument("--url", default=os.getenv("IRIS_WS_URL", DEFAULT_URL))
p.add_argument("--token", default="")
p.add_argument("--skip", default="",
help="comma-separated scenario numbers to skip (e.g. 3,5,7)")
@@ -360,12 +360,12 @@ def main() -> int:
token = find_token(args.token)
if not token:
print("!! ANDROID_TOKEN not found (env, hermes-agent/.env, or ~/.hermes/.env)")
print("!! IRIS_TOKEN not found (env, hermes-agent/.env, or ~/.hermes/.env)")
return 1
skip = {int(x) for x in args.skip.split(",") if x.strip()}
env = dict(os.environ)
env["ANDROID_TOKEN"] = token
env["IRIS_TOKEN"] = token
print(f"== e2e: url={args.url} token={token[:6]}…")
sweep_leftovers(env, args.url, token)
+90 -90
View File
@@ -1,7 +1,7 @@
"""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
``~/.hermes/plugins/iris`` 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):
@@ -42,12 +42,12 @@ PNG_1X1 = base64.b64decode(
"AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
)
TOKEN = "test-android-token-0123456789"
TOKEN = "test-iris-token-0123456789"
DEVICE_ID = "test-device"
def _plugin_dir() -> Path:
env = os.environ.get("ANDROID_PLUGIN_DIR")
env = os.environ.get("IRIS_PLUGIN_DIR")
if env:
return Path(env)
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
@@ -66,13 +66,13 @@ def _load_plugin():
The plugin uses relative imports (``from . import protocol``), so it
must be imported as a package (``submodule_search_locations``).
"""
name = "android_plugin_under_test"
name = "iris_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}")
pytest.fail(f"iris plugin not found at {pkg_dir}")
spec = importlib.util.spec_from_file_location(
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
)
@@ -95,16 +95,16 @@ def plugin():
@pytest.fixture
def adapter(plugin, monkeypatch):
"""A live AndroidAdapter with an isolated HERMES_HOME (conftest)."""
monkeypatch.setenv("ANDROID_TOKEN", TOKEN)
"""A live IrisAdapter with an isolated HERMES_HOME (conftest)."""
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
from gateway.platform_registry import PlatformEntry, platform_registry
# Platform("android") resolves only once the platform is registered
# Platform("iris") resolves only once the platform is registered
# (the plugin's register(ctx) does this in production).
if not platform_registry.is_registered("android"):
if not platform_registry.is_registered("iris"):
platform_registry.register(
PlatformEntry(
name="android",
name="iris",
label="Android",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
@@ -119,7 +119,7 @@ def adapter(plugin, monkeypatch):
},
home_channel=None,
)
a = plugin.adapter.AndroidAdapter(config)
a = plugin.adapter.IrisAdapter(config)
yield a
try:
a._devices.close()
@@ -459,14 +459,14 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client,
ws, _ = ws_client
# Simulate the post_api_request hook capturing the turn's model + tokens.
plugin.adapter._on_post_api_request(
platform="android",
platform="iris",
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})
res = await adapter.send("default", "hello", metadata={"notify": True})
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "message")
msg = frames[-1]
@@ -477,7 +477,7 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client,
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})
res2 = await adapter.send("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"
@@ -492,7 +492,7 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client,
@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.
# A non-iris turn must not pollute the iris runtime buffer.
plugin.adapter._on_post_api_request(
platform="telegram",
model="openai/gpt-5.4",
@@ -500,7 +500,7 @@ async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client
)
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})
res = await adapter.send("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")
@@ -512,15 +512,15 @@ async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client
async def test_history_preserves_runtime_footer(plugin, adapter, ws_client, monkeypatch):
ws, _ = ws_client
plugin.adapter._on_post_api_request(
platform="android",
platform="iris",
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})
await adapter.send("default", "hello", metadata={"notify": True})
# The outbox history reconstruction must carry the runtime object.
page = adapter._outbox.history("android:default", limit=50)
page = adapter._outbox.history("default", limit=50)
assert len(page["messages"]) == 1
m = page["messages"][0]
assert m.get("runtime", {}).get("model") == "gpt-5.4"
@@ -722,7 +722,7 @@ async def test_message_send_auto_thread_creates_named_thread(adapter, ws_client,
"v": 1,
"id": 40,
"type": "message.send",
"chat_id": "android:default",
"chat_id": "default",
"payload": {"text": "fix the login bug please", "auto_thread": True},
}
)
@@ -733,16 +733,16 @@ async def test_message_send_auto_thread_creates_named_thread(adapter, ws_client,
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"]["parent_chat_id"] == "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["chat_id"] == "default"
assert echo["thread_id"] == thread_id
assert len(captured) == 1
assert captured[0].source.chat_id == "android:default"
assert captured[0].source.chat_id == "default"
assert captured[0].source.thread_id == thread_id
@@ -762,7 +762,7 @@ async def test_message_send_auto_thread_llm_upgrade_renames(adapter, ws_client,
"v": 1,
"id": 41,
"type": "message.send",
"chat_id": "android:default",
"chat_id": "default",
"payload": {"text": "fix the login bug please", "auto_thread": True},
}
)
@@ -789,7 +789,7 @@ async def test_message_send_auto_thread_ignored_with_existing_thread(adapter, ws
"v": 1,
"id": 42,
"type": "message.send",
"chat_id": "android:default",
"chat_id": "default",
"thread_id": "t_9",
"payload": {"text": "follow up", "auto_thread": True},
}
@@ -818,7 +818,7 @@ async def test_message_send_auto_thread_ignored_for_slash_command(adapter, ws_cl
"v": 1,
"id": 43,
"type": "message.send",
"chat_id": "android:default",
"chat_id": "default",
"payload": {"text": "/new", "auto_thread": True},
}
)
@@ -848,7 +848,7 @@ async def test_message_send_auto_thread_media_only_stays_flat(adapter, ws_client
"v": 1,
"id": 44,
"type": "message.send",
"chat_id": "android:default",
"chat_id": "default",
"payload": {"text": "", "media_refs": ["mu_flat"], "auto_thread": True},
}
)
@@ -877,7 +877,7 @@ async def test_user_echo_parked_in_outbox(adapter, ws_client):
"v": 1,
"id": 20,
"type": "message.send",
"chat_id": "android:default",
"chat_id": "default",
"payload": {"text": "persist me"},
}
)
@@ -888,7 +888,7 @@ async def test_user_echo_parked_in_outbox(adapter, ws_client):
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)
page = adapter._outbox.history("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)
@@ -904,7 +904,7 @@ async def test_history_returns_final_messages_oldest_first(plugin, adapter, ws_c
ws, _ = ws_client
protocol = plugin.protocol
adapter.handle_message = AsyncMock()
chat_id = "android:default"
chat_id = "default"
# Two user turns, each with a (non-streaming) assistant final.
for i, text in enumerate(["one", "two"]):
await ws.send(
@@ -952,7 +952,7 @@ async def test_history_paginates_older_pages(plugin, adapter, ws_client):
ws, _ = ws_client
protocol = plugin.protocol
adapter.handle_message = AsyncMock()
chat_id = "android:default"
chat_id = "default"
for i in range(5):
await adapter._broadcast_or_log(
chat_id,
@@ -982,7 +982,7 @@ async def test_history_frame_roundtrip(plugin, adapter, ws_client):
ws, _ = ws_client
protocol = plugin.protocol
adapter.handle_message = AsyncMock()
chat_id = "android:default"
chat_id = "default"
await adapter._broadcast_or_log(
chat_id,
protocol.message(
@@ -1023,7 +1023,7 @@ async def test_message_delete_removes_from_outbox_and_broadcasts(plugin, adapter
ws, _ = ws_client
protocol = plugin.protocol
adapter.handle_message = AsyncMock()
chat_id = "android:default"
chat_id = "default"
await adapter._broadcast_or_log(
chat_id,
protocol.message(
@@ -1062,7 +1062,7 @@ async def test_message_delete_idempotent(plugin, adapter, ws_client):
on the outbox but still emits ``message.deleted`` so live caches drop it."""
ws, _ = ws_client
adapter.handle_message = AsyncMock()
chat_id = "android:default"
chat_id = "default"
await ws.send(
json.dumps(
{
@@ -1088,7 +1088,7 @@ async def test_message_delete_requires_message_ids(adapter, ws_client):
"v": 1,
"id": 52,
"type": "message.delete",
"chat_id": "android:default",
"chat_id": "default",
"payload": {},
}
)
@@ -1125,7 +1125,7 @@ async def test_message_delete_purges_session_store(plugin, adapter, ws_client):
adapter.handle_message = AsyncMock()
from hermes_constants import get_hermes_home
chat_id = "android:default"
chat_id = "default"
db = get_hermes_home() / "state.db"
_make_state_db(db)
import sqlite3
@@ -1244,12 +1244,12 @@ async def test_send_video_emits_offer_with_message_association(adapter, ws_clien
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})
res = await adapter.send("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))
res2 = await adapter.send_video("default", str(video))
assert res2.success
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
offer = frames[-1]["payload"]
@@ -1270,7 +1270,7 @@ async def test_send_document_uses_caller_filename(adapter, ws_client):
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"
"default", str(doc), file_name="My Report.pdf"
)
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
@@ -1287,7 +1287,7 @@ async def test_send_image_file_offers_image(adapter, ws_client):
img = get_image_cache_dir() / "shot.png"
img.write_bytes(PNG_1X1)
res = await adapter.send_image_file("android:default", str(img))
res = await adapter.send_image_file("default", str(img))
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
offer = frames[-1]["payload"]
@@ -1299,7 +1299,7 @@ async def test_send_image_file_offers_image(adapter, ws_client):
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")
res = await adapter.send_document("default", "/etc/passwd")
assert not res.success
# Nothing was offered (a failed offer is silent, like other platforms).
try:
@@ -1439,10 +1439,10 @@ async def test_ntfy_backend_publishes_with_data_header(plugin, monkeypatch):
)
ok = await backend.send(
device_id="d1",
chat_id="android:default",
chat_id="default",
title="Iris",
body="hello",
data={"chat_id": "android:default", "kind": "message", "cursor": "7"},
data={"chat_id": "default", "kind": "message", "cursor": "7"},
token="iris-topic",
)
assert ok is True
@@ -1479,10 +1479,10 @@ async def test_fcm_backend_legacy_server_key(plugin, monkeypatch):
)
ok = await backend.send(
device_id="d1",
chat_id="android:default",
chat_id="default",
title="Iris",
body="hi",
data={"chat_id": "android:default", "kind": "message", "cursor": "3"},
data={"chat_id": "default", "kind": "message", "cursor": "3"},
token="fcm-token-1",
)
assert ok is True
@@ -1528,10 +1528,10 @@ async def test_fcm_backend_service_account_v1(plugin, monkeypatch, tmp_path):
fake = _patch_httpx(plugin, monkeypatch, responder)
ok = await backend.send(
device_id="d1",
chat_id="android:default",
chat_id="default",
title="Iris",
body="hi",
data={"chat_id": "android:default", "kind": "cron", "cursor": "4"},
data={"chat_id": "default", "kind": "cron", "cursor": "4"},
token="fcm-token-2",
priority="high",
)
@@ -1584,15 +1584,15 @@ async def test_push_fires_when_no_live_subscriber(adapter):
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
res = await adapter.send(
"android:default", "hello while offline", metadata={"notify": True}
"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["chat_id"] == "default"
assert call["data"]["kind"] == "message"
assert call["data"]["chat_id"] == "android:default"
assert call["data"]["chat_id"] == "default"
assert call["data"]["cursor"] == "1"
assert call["priority"] == "normal"
# The frame is parked in the outbox for sync.
@@ -1606,7 +1606,7 @@ async def test_push_not_fired_when_live(adapter, ws_client):
adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
await adapter.send("android:default", "live reply", metadata={"notify": True})
await adapter.send("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 == []
@@ -1619,7 +1619,7 @@ async def test_live_delivered_frame_still_parked_for_sync(adapter, ws_client):
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})
await adapter.send("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.
@@ -1674,9 +1674,9 @@ async def test_intermediate_frames_park_without_push(adapter):
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")
await adapter.send("default", "seg", metadata={"expect_edits": True})
stream_id = adapter._turns["default"].stream_id
await adapter.edit_message("default", stream_id, "seg more")
assert adapter._outbox.latest_cursor() == 2
assert fake.calls == []
@@ -1689,9 +1689,9 @@ async def test_high_priority_notification_pushes_even_when_live(plugin, adapter,
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
await adapter._broadcast_or_log(
"android:default",
"default",
plugin.protocol.notification(
"android:default", plugin.protocol.NOTIF_CRON, "Cron: Job", "body"
"default", plugin.protocol.NOTIF_CRON, "Cron: Job", "body"
),
)
frames = await recv_until(ws, lambda f: f.get("type") == "notification")
@@ -1707,7 +1707,7 @@ async def test_push_skipped_when_device_has_no_token(adapter):
adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}) # no push token
await adapter.send("android:default", "no token", metadata={"notify": True})
await adapter.send("default", "no token", metadata={"notify": True})
assert fake.calls == []
assert adapter._outbox.latest_cursor() == 1 # still parked for sync
@@ -1718,7 +1718,7 @@ async def test_push_skipped_when_backend_unconfigured(adapter):
adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {})
await adapter.send("android:default", "unconfigured", metadata={"notify": True})
await adapter.send("default", "unconfigured", metadata={"notify": True})
assert fake.calls == []
@@ -1755,7 +1755,7 @@ async def test_fcm_register_updates_registry(adapter, ws_client):
adapter._http_server._subs.clear()
fake = _FakePush()
adapter._push = fake
await adapter.send("android:default", "after rotation", metadata={"notify": True})
await adapter.send("default", "after rotation", metadata={"notify": True})
assert len(fake.calls) == 1
assert fake.calls[0]["token"] == "rotated-token"
@@ -1854,7 +1854,7 @@ async def test_channel_favorite_toggle(adapter, ws_client):
@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}}))
await ws.send(json.dumps({"v": 1, "id": 1, "type": "channel.favorite", "chat_id": "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"
@@ -1909,7 +1909,7 @@ async def test_cron_delivery_emits_banner_and_message(adapter, ws_client):
"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"})
res = await adapter.send("default", wrapped, metadata={"job_id": "abc123"})
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "message")
msg = frames[-1]
@@ -1922,7 +1922,7 @@ async def test_cron_delivery_emits_banner_and_message(adapter, ws_client):
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"})
res2 = await adapter.send("default", "raw cron output", metadata={"job_id": "j2"})
assert res2.success
frames = await recv_until(
ws,
@@ -1941,7 +1941,7 @@ async def test_clarify_emits_banner_and_message(adapter, ws_client):
cg.register("cl_1", "sk", "Which one?", ["A", "B"])
try:
res = await adapter.send_clarify(
"android:default", "Which one?", ["A", "B"], "cl_1", "sk"
"default", "Which one?", ["A", "B"], "cl_1", "sk"
)
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "message")
@@ -1964,8 +1964,8 @@ async def test_clarify_emits_banner_and_message(adapter, ws_client):
@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})
await adapter.send("default", "one", metadata={"notify": True})
await adapter.send("default", "two", metadata={"notify": True})
assert adapter._outbox.latest_cursor() == 2
await adapter.connect()
@@ -2021,26 +2021,26 @@ async def test_push_success_advances_last_pushed_cursor(adapter):
adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
await adapter.send("android:default", "one", metadata={"notify": True})
await adapter.send("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})
await adapter.send("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})
adapter._last_push_at["default"] = 0.0
await adapter.send("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})
adapter._last_push_at["default"] = 0.0
await adapter.send("default", "four", metadata={"notify": True})
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
await adapter.connect()
@@ -2058,8 +2058,8 @@ 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.send("default", "one", metadata={"notify": True})
await adapter.send("default", "two", metadata={"notify": True})
await adapter.connect()
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
@@ -2083,7 +2083,7 @@ 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})
await adapter.send("default", "live", metadata={"notify": True})
frames = await recv_until(ws, lambda f: f.get("type") == "message")
assert "cursor" not in frames[-1]
@@ -2093,7 +2093,7 @@ def test_outbox_row_cap_prunes_oldest(plugin, tmp_path):
try:
for i in range(7):
outbox.append(
"android:default",
"default",
json.dumps({"v": 1, "type": "message", "payload": {"n": i}}),
)
assert outbox.latest_cursor() == 7 # cursor stays monotonic
@@ -2110,7 +2110,7 @@ def test_outbox_delete_message_removes_all_frames_for_id(plugin, tmp_path):
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"
chat = "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"}}))
@@ -2139,12 +2139,12 @@ def test_outbox_delete_lane_removes_channel_and_thread_frames(plugin, tmp_path):
scoped to that thread's frames only."""
outbox = plugin.outbox.Outbox(tmp_path / "ob.db")
try:
chan = "android:chan_9"
chan = "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"}}))
outbox.append("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)
@@ -2164,7 +2164,7 @@ def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path):
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")
d.ensure_default("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.
@@ -2180,10 +2180,10 @@ def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path):
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
assert d.delete("default") is None
assert d.get("default") is not None
# Unknown id -> None.
assert d.delete("android:chan_nope") is None
assert d.delete("chan_nope") is None
finally:
d.close()
@@ -2272,14 +2272,14 @@ def test_tool_end_fields_from_hook(plugin):
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(
name, preview, args = a.IrisAdapter._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(
name2, preview2, args2 = a.IrisAdapter._parse_tool_line_or_block(
'🔍 web_search: "x"', '🔍 web_search: "x"'
)
assert name2 == "web_search" and preview2 == "x" and args2 is None
@@ -2289,11 +2289,11 @@ def test_tool_start_frame_emoji_field(plugin):
"""``tool.start`` carries the cosmetic emoji when given, omits it when
None (the app then falls back to its own default glyph)."""
pf = plugin.protocol.tool_start
f = pf("android:default", 3, "terminal", emoji="💻")
f = pf("default", 3, "terminal", emoji="💻")
assert f.payload["emoji"] == "💻"
f2 = pf("android:default", 3, "terminal")
f2 = pf("default", 3, "terminal")
assert "emoji" not in f2.payload
f3 = pf("android:default", 3, "terminal", emoji=None)
f3 = pf("default", 3, "terminal", emoji=None)
assert "emoji" not in f3.payload
@@ -2330,7 +2330,7 @@ async def test_tool_start_frame_carries_emoji(plugin, adapter, ws_client, monkey
"agent.display.get_tool_emoji",
lambda name, default="⚡": "💻" if name == "terminal" else default,
)
res = await adapter.send('android:default', '💻 terminal: "ls -la"')
res = await adapter.send('default', '💻 terminal: "ls -la"')
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "tool.start")
payload = frames[-1]["payload"]
@@ -2338,7 +2338,7 @@ async def test_tool_start_frame_carries_emoji(plugin, adapter, ws_client, monkey
assert payload["emoji"] == "💻"
# Unknown tool -> field omitted (app falls back to its default glyph).
monkeypatch.setattr("agent.display.get_tool_emoji", lambda name, default="⚡": default)
res2 = await adapter.send('android:default', '🔧 patch: "x"')
res2 = await adapter.send('default', '🔧 patch: "x"')
assert res2.success
frames2 = await recv_until(
ws, lambda f: f.get("type") == "tool.start" and f["payload"]["name"] == "patch"
+11 -11
View File
@@ -1,4 +1,4 @@
"""Tests for the android plugin's HTTP fallback transport (docs/19).
"""Tests for the iris plugin's HTTP fallback transport (docs/19).
The plugin lives in the sibling ``iris_x_hermes`` checkout; tests load it
from the source tree directly (same pattern as ``test_android.py``).
@@ -44,9 +44,9 @@ import pytest_asyncio
# Test-only token (not a credential; the adapter is built with it via
# monkeypatch in the fixture below).
# pi-lens-ignore: S105
TOKEN = "test-android-http-token-0123456789"
TOKEN = "test-iris-http-token-0123456789"
DEVICE_ID = "test-http-device"
CHAT_ID = "android:default"
CHAT_ID = "default"
# 1x1 PNG (same fixture as test_android.py).
PNG_1X1 = base64.b64decode(
@@ -56,7 +56,7 @@ PNG_1X1 = base64.b64decode(
def _plugin_dir() -> Path:
env = os.environ.get("ANDROID_PLUGIN_DIR")
env = os.environ.get("IRIS_PLUGIN_DIR")
if env:
return Path(env)
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
@@ -72,13 +72,13 @@ def _plugin_dir() -> Path:
def _load_plugin():
"""Load the gateway-plugin package under a unique module name (same
pattern as test_android.py)."""
name = "android_plugin_http_under_test"
name = "iris_plugin_http_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}")
pytest.fail(f"iris plugin not found at {pkg_dir}")
spec = importlib.util.spec_from_file_location(
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
)
@@ -101,14 +101,14 @@ def plugin():
@pytest.fixture
def adapter(plugin, monkeypatch):
"""A live AndroidAdapter with an isolated HERMES_HOME (conftest)."""
monkeypatch.setenv("ANDROID_TOKEN", TOKEN)
"""A live IrisAdapter with an isolated HERMES_HOME (conftest)."""
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
from gateway.platform_registry import PlatformEntry, platform_registry
if not platform_registry.is_registered("android"):
if not platform_registry.is_registered("iris"):
platform_registry.register(
PlatformEntry(
name="android",
name="iris",
label="Android",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
@@ -124,7 +124,7 @@ def adapter(plugin, monkeypatch):
},
home_channel=None,
)
a = plugin.adapter.AndroidAdapter(config)
a = plugin.adapter.IrisAdapter(config)
yield a
with contextlib.suppress(Exception):
a._devices.close()
+9 -9
View File
@@ -7,13 +7,13 @@ while building the Kotlin client.
Usage::
hermes gateway & # with the android plugin
python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> \
hermes gateway & # with the iris plugin
python gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> \
--send "hello"
Options:
--url ws://host:port/ws (default ws://127.0.0.1:8790/ws)
--token ANDROID_TOKEN (default: $ANDROID_TOKEN)
--token IRIS_TOKEN (default: $IRIS_TOKEN)
--device device_id (default: probe-<rand>)
--send TEXT send this message after pairing (default: "hello")
--upload F M4: upload F (chunked media.upload) and attach it to the
@@ -573,7 +573,7 @@ def run_http(args, base: str) -> int:
"v": 1,
"id": 1,
"type": "message.send",
"chat_id": "android:default",
"chat_id": "default",
"payload": {"text": args.send},
}
conn = HTTPConnection(host, port, timeout=30)
@@ -666,8 +666,8 @@ def run_http(args, base: str) -> int:
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--url", default=os.getenv("ANDROID_WS_URL", "ws://127.0.0.1:8790/ws"))
p.add_argument("--token", default=os.getenv("ANDROID_TOKEN", ""))
p.add_argument("--url", default=os.getenv("IRIS_WS_URL", "ws://127.0.0.1:8790/ws"))
p.add_argument("--token", default=os.getenv("IRIS_TOKEN", ""))
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
p.add_argument("--send", default="hello")
p.add_argument(
@@ -724,8 +724,8 @@ def main() -> int:
)
p.add_argument(
"--chat-id",
default="android:default",
help="chat_id for --scope chat (default android:default)",
default="default",
help="chat_id for --scope chat (default default)",
)
p.add_argument(
"--channel-create", default="", help="M3: create a channel, print its chat_id, exit"
@@ -762,7 +762,7 @@ def main() -> int:
)
args = p.parse_args()
if not args.token and not args.authfail:
p.error("--token (or $ANDROID_TOKEN) is required")
p.error("--token (or $IRIS_TOKEN) is required")
if args.assert_read_receipt and not args.send:
p.error("--assert-read-receipt requires --send (the receipt must follow the sent message)")
# HTTP is the only transport (docs/19): derive the http(s) base from the