Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
This commit is contained in:
1 parent
27dc7917f2
commit
7a6d922d12
63 files changed
+2073
-630
No files matched your search
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user