1"""Exercise real Hermes waiters through the Tiny adapter's public hooks."""23import asyncio4import json5import sys6import time7from pathlib import Path89import pytest1011sys.path.insert(0, str(Path(__file__).parents[1]))12from adapter import TinyAdapter13from gateway.config import PlatformConfig14from gateway.platform_registry import PlatformEntry, platform_registry15from gateway.platforms.base import ExecApprovalPrompt16from tools import approval, clarify_gateway, slash_confirm17from tools.approval_gateway_wait import _ApprovalEntry1819OWNER = "b" * 6420BOT = "a" * 6421OTHER = "c" * 64222324class FakeRPC:25 def __init__(self):26 self.events = []2728 async def publish(self, event):29 result = {"id": f"{len(self.events) + 1:064x}", "created_at": int(time.time()), "pubkey": BOT, **event}30 self.events.append(result)31 return result32333435def adapter(monkeypatch, tmp_path):36 monkeypatch.setenv("HERMES_HOME", str(tmp_path))37 platform_registry.register(PlatformEntry(name="tiny", label="Tiny", adapter_factory=TinyAdapter,38 check_fn=lambda: True))39 instance = TinyAdapter(PlatformConfig(enabled=True, extra={"rooms": "room", "allowed_users": f"{OWNER},{OTHER}"}))40 instance.rpc = FakeRPC()41 instance.pubkey = BOT42 instance._session_assignees["session"] = OWNER43 yield instance44 clarify_gateway._entries.clear()45 clarify_gateway._session_index.clear()46 approval._gateway_queues.clear()47 slash_confirm._pending.clear()484950def response(request, content, author=OWNER):51 event_id = request["id"]52 return {"kind": 1111, "pubkey": author, "created_at": int(time.time()), "content": content,53 "tags": [["h", "room"], ["e", event_id], ["E", event_id], ["p", BOT], ["P", BOT], ["k", "9"], ["K", "9"]]}545556def test_real_inbound_source_tracks_human_session(adapter):57 async def run():58 received = []59 async def handle(message):60 received.append(message)61 adapter.handle_message = handle62 await adapter._on_event({"kind": 9, "id": "d" * 64, "pubkey": OWNER, "created_at": int(time.time()),63 "content": "hello", "tags": [["h", "room"], ["p", BOT]]})64 assert len(received) == 165 key = adapter._event_session_key(received[0])66 assert adapter._session_assignees[key] == OWNER67 assert received[0].source.platform.value == "tiny"68 asyncio.run(run())697071727374def test_clarify_resolves_exact_real_waiter(adapter, multiple, content, expected):75 async def run():76 entry = clarify_gateway.register("question-id", "session", "Pick", ["alpha", "beta"], multiple)77 untouched = clarify_gateway.register("other-question", "session", "Other", ["yes"])78 result = await adapter.send_clarify("room", "Pick", ["alpha", "beta"], "question-id", "session")79 assert result.success80 request = adapter.rpc.events[-1]81 assert ["p", OWNER] in request["tags"]82 assert not any(tag[0] == "mention" for tag in request["tags"])83 assert ["freeform", "true"] in request["tags"]84 assert all("session" not in str(tag) for tag in request["tags"])85 await adapter._on_answer(response(request, content))86 assert entry.event.is_set() and entry.response == expected87 assert not untouched.event.is_set()88 asyncio.run(run())89909192def test_invalid_answers_do_not_resolve(adapter, mutation):93 async def run():94 entry = clarify_gateway.register("question-id", "session", "Pick", ["alpha", "beta"], True)95 await adapter.send_clarify("room", "Pick", ["alpha", "beta"], "question-id", "session")96 request = adapter.rpc.events[-1]97 answer = response(request, '["c0"]')98 if mutation == "actor": answer["pubkey"] = OTHER99 if mutation == "room": answer["tags"][0][1] = "elsewhere"100 if mutation == "root": answer["tags"][2][1] = "f" * 64101 if mutation == "kind": answer["kind"] = 7102 if mutation == "invalid": answer["content"] = '["unknown"]'103 if mutation == "duplicate": answer["content"] = '["c0","c0"]'104 if mutation == "expired": adapter._pending[request["id"]].expires = int(time.time()) - 1105 await adapter._on_answer(answer)106 assert not entry.event.is_set()107 asyncio.run(run())108109110def test_open_ended_question_is_structured(adapter):111 async def run():112 entry = clarify_gateway.register("question-id", "session", "Explain", None)113 await adapter.send_clarify("room", "Explain", None, "question-id", "session")114 request = adapter.rpc.events[-1]115 assert ["selection", "text"] in request["tags"]116 await adapter._on_answer(response(request, "Here is my answer"))117 assert entry.response == "Here is my answer"118 asyncio.run(run())119120121def test_approval_uses_exact_request_and_offered_scope(adapter):122 async def run():123 first = _ApprovalEntry({"command": "first", "request_id": "first-id"})124 second = _ApprovalEntry({"command": "second", "request_id": "second-id"})125 approval._gateway_queues["session"] = [first, second]126 prompt = ExecApprovalPrompt("room", "session", "Allow second?", [("Once", "once", "primary"),127 ("Deny", "deny", "danger")], "second", "description", False)128 assert (await adapter._send_exec_approval_prompt(prompt)).success129 request = adapter.rpc.events[-1]130 await adapter._on_answer(response(request, "always"))131 assert not first.event.is_set() and not second.event.is_set()132 await adapter._on_answer(response(request, "once"))133 assert not first.event.is_set() and second.event.is_set()134 assert second.result == "once"135 await adapter._on_answer(response(request, "once"))136 assert not first.event.is_set()137 asyncio.run(run())138139140def test_confirmation_calls_real_handler_once(adapter):141 async def run():142 choices = []143 async def handler(choice):144 choices.append(choice)145 return "Reloaded"146 slash_confirm.register("session", "confirm-id", "reload", handler)147 await adapter.send_slash_confirm("room", "Reload?", "Confirm", "session", "confirm-id")148 request = adapter.rpc.events[-1]149 await adapter._on_answer(response(request, "once"))150 await adapter._on_answer(response(request, "once"))151 assert choices == ["once"]152 assert adapter.rpc.events[-1]["content"] == "Reloaded"153 asyncio.run(run())154155156def test_explicit_mentions_and_edits(adapter):157 async def run():158 result = await adapter.send("room", "ordinary", reply_to="d" * 64)159 assert not any(tag[0] in ("p", "mention") for tag in adapter.rpc.events[-1]["tags"])160 edited = await adapter.edit_message("room", result.message_id, f"Attention @{OWNER}")161 assert edited.message_id == result.message_id162 event = adapter.rpc.events[-1]163 assert event["kind"] == 40003164 assert ["p", OWNER] in event["tags"] and ["mention", OWNER] in event["tags"]165 assert not await adapter.delete_message("room", result.message_id)166 asyncio.run(run())167168169def test_native_prompt_mentions_assignee_only_when_explicit(adapter):170 async def run():171 clarify_gateway.register("q", "session", "Pick", ["yes"])172 await adapter.send_clarify("room", f"@{OWNER} Pick", ["yes"], "q", "session")173 assert ["mention", OWNER] in adapter.rpc.events[-1]["tags"]174 asyncio.run(run())175176177def test_outbound_document_preserves_thread_caption_and_bytes(adapter, tmp_path):178 import hashlib179 data = b'attachment bytes'180 path = tmp_path / 'note.txt'181 path.write_bytes(data)182 digest = hashlib.sha256(data).hexdigest()183 uploads = []184 async def upload(room, name, mime, body):185 uploads.append((room, name, mime, body))186 return {'url': f'http://tiny/media/{digest}', 'sha256': digest}187 adapter.rpc.upload = upload188 result = asyncio.run(adapter.send_document(chat_id='room', file_path=str(path),189 file_name='renamed.txt', caption=f'For @{OWNER}', metadata={'thread_id': 'd' * 64}, reply_to='e' * 64))190 assert result.success191 event = adapter.rpc.events[-1]192 assert uploads == [('room', 'renamed.txt', 'text/plain', data)]193 assert event['kind'] == 12194 assert ['e', 'd' * 64, '', 'root'] in event['tags']195 assert ['e', 'e' * 64, '', 'reply'] in event['tags']196 assert ['p', OWNER] in event['tags'] and ['mention', OWNER] in event['tags']197 assert any(t[0] == 'imeta' and f'x {digest}' in t and 'size 16' in t for t in event['tags'])198199200def test_standalone_delivery_uses_native_media(adapter, tmp_path, monkeypatch):201 import adapter as module202 import hashlib203 path = tmp_path / 'voice.mp3'204 path.write_bytes(b'audio bytes')205 rpc = adapter.rpc206 async def connect(): pass207 async def close(): pass208 async def upload(room, name, mime, body):209 digest = hashlib.sha256(body).hexdigest()210 return {'url': f'http://tiny/media/{digest}', 'sha256': digest}211 rpc.connect, rpc.close, rpc.upload = connect, close, upload212 monkeypatch.setattr(module, 'TinyRPC', lambda *a, **kw: rpc)213 result = asyncio.run(module._standalone_send(adapter.config, 'room', f'For @{OWNER}',214 thread_id='d' * 64, media_files=[(str(path), True)]))215 assert result['success'] and result['media_delivered']216 assert len(rpc.events) == 1217 event = rpc.events[0]218 assert event['kind'] == 12219 assert ['mention', OWNER] in event['tags']220 assert any(t[0] == 'imeta' and 'm audio/mpeg' in t for t in event['tags'])221222223def test_outbound_media_rejects_wrong_relay_hash(adapter, tmp_path):224 path = tmp_path / 'note.txt'225 path.write_text('local content')226 async def upload(*args): return {'url': 'http://tiny/media/' + '0' * 64, 'sha256': '0' * 64}227 adapter.rpc.upload = upload228 result = asyncio.run(adapter.send_document('room', str(path)))229 assert not result.success and adapter.rpc.events == []230231232def test_duplicate_callbacks_cannot_race_before_media_intake(adapter, monkeypatch):233 import adapter as module234 received = []235 async def media(*args):236 await asyncio.sleep(0.01)237 return [], []238 async def handle(message): received.append(message)239 monkeypatch.setattr(module, 'incoming_media', media)240 adapter.handle_message = handle241 event = {'kind': 9, 'id': 'e' * 64, 'pubkey': OWNER, 'created_at': int(time.time()),242 'content': 'hello', 'tags': [['h', 'room'], ['p', BOT]]}243 async def run(): await asyncio.gather(adapter._on_event(event), adapter._on_event(event))244 asyncio.run(run())245 assert len(received) == 1