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

+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"