Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
"""RelayAdapter capability-advertisement tests (relay Phase 1, Task 1.1)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.relay.adapter import RelayAdapter
|
||||
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
|
||||
|
||||
|
||||
def make_desc(**kw) -> CapabilityDescriptor:
|
||||
base = dict(
|
||||
contract_version=CONTRACT_VERSION,
|
||||
platform="telegram",
|
||||
label="Telegram",
|
||||
max_message_length=4096,
|
||||
supports_draft_streaming=False,
|
||||
supports_edit=True,
|
||||
supports_threads=True,
|
||||
markdown_dialect="markdown_v2",
|
||||
len_unit="utf16",
|
||||
emoji="\u2708\ufe0f",
|
||||
platform_hint="",
|
||||
pii_safe=False,
|
||||
)
|
||||
base.update(kw)
|
||||
return CapabilityDescriptor(**base)
|
||||
|
||||
|
||||
def _adapter(**desc_kw) -> RelayAdapter:
|
||||
return RelayAdapter(PlatformConfig(), make_desc(**desc_kw))
|
||||
|
||||
|
||||
def test_relay_platform_member_exists():
|
||||
assert Platform("relay") is Platform.RELAY
|
||||
|
||||
|
||||
def test_advertises_descriptor_max_length():
|
||||
a = _adapter(max_message_length=2000)
|
||||
assert a.MAX_MESSAGE_LENGTH == 2000
|
||||
|
||||
|
||||
def test_supports_draft_streaming_follows_descriptor():
|
||||
# NS-658: the flag alone no longer advertises drafts — before the
|
||||
# "draft" op existed, flag=True was a latent lie (send_draft inherited
|
||||
# NotImplementedError). Advertisement now requires flag AND op.
|
||||
assert _adapter(supports_draft_streaming=False).supports_draft_streaming() is False
|
||||
assert (
|
||||
_adapter(supports_draft_streaming=False, supported_ops=("send", "draft"))
|
||||
.supports_draft_streaming()
|
||||
is False
|
||||
), "op without flag must not advertise"
|
||||
assert (
|
||||
_adapter(supports_draft_streaming=True, supported_ops=("send", "draft"))
|
||||
.supports_draft_streaming()
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_len_fn_utf16_counts_code_units():
|
||||
a = _adapter(len_unit="utf16")
|
||||
# An astral-plane emoji is two UTF-16 code units.
|
||||
assert a.message_len_fn("\U0001f600") == 2
|
||||
|
||||
|
||||
def test_is_a_base_platform_adapter():
|
||||
# stream_consumer's isinstance(adapter, BasePlatformAdapter) guard must pass.
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
|
||||
assert isinstance(_adapter(), BasePlatformAdapter)
|
||||
|
||||
|
||||
def test_connect_signature_matches_base_contract():
|
||||
"""The is_reconnect parameter must be keyword-accepting and default False,
|
||||
matching BasePlatformAdapter.connect, so the reconnect watcher's
|
||||
``connect(is_reconnect=...)`` call is valid for relay as for every other
|
||||
adapter."""
|
||||
import inspect
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
|
||||
sig = inspect.signature(RelayAdapter.connect)
|
||||
base_sig = inspect.signature(BasePlatformAdapter.connect)
|
||||
assert "is_reconnect" in sig.parameters
|
||||
param = sig.parameters["is_reconnect"]
|
||||
base_param = base_sig.parameters["is_reconnect"]
|
||||
# Keyword-acceptable (KEYWORD_ONLY here, matching the base) with a False default.
|
||||
assert param.kind is base_param.kind
|
||||
assert param.default is False
|
||||
|
||||
|
||||
class _CaptureTransport:
|
||||
"""Minimal RelayTransport stand-in that records the outbound action."""
|
||||
|
||||
def __init__(self):
|
||||
self.sent = None
|
||||
self.sent_platform = None
|
||||
# No concrete fronted identities ⇒ _platform_is_fronted is a no-op here.
|
||||
self._identities = []
|
||||
|
||||
def set_inbound_handler(self, h): # noqa: D401
|
||||
self._h = h
|
||||
|
||||
async def send_outbound(self, action, *, platform=None):
|
||||
self.sent = action
|
||||
self.sent_platform = platform
|
||||
return {"success": True, "message_id": "m1"}
|
||||
|
||||
|
||||
def _make_event(chat_id="chan-1", scope_id="scope-9"):
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.session import SessionSource
|
||||
|
||||
src = SessionSource(
|
||||
platform=Platform.RELAY,
|
||||
chat_id=chat_id,
|
||||
chat_type="channel",
|
||||
scope_id=scope_id,
|
||||
)
|
||||
return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
|
||||
|
||||
|
||||
def _make_dm_event(chat_id="dm-1", user_id="user-42"):
|
||||
"""An inbound DM: no scope_id, carries the authentic author user_id."""
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.session import SessionSource
|
||||
|
||||
src = SessionSource(
|
||||
platform=Platform.RELAY,
|
||||
chat_id=chat_id,
|
||||
chat_type="dm",
|
||||
scope_id=None,
|
||||
user_id=user_id,
|
||||
)
|
||||
return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
|
||||
|
||||
|
||||
def _make_scoped_event_with_author(
|
||||
chat_id="chan-1", scope_id="scope-9", user_id="user-42"
|
||||
):
|
||||
"""An inbound scoped (guild/channel) message that ALSO carries the authentic
|
||||
author user_id — the real shape of a Discord guild message (it has both a
|
||||
guild scope_id and an author). Used to prove the adapter re-attaches BOTH
|
||||
discriminators so the connector can fall back author-first when the guild
|
||||
has no route row (managed agents join guilds dynamically)."""
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.session import SessionSource
|
||||
|
||||
src = SessionSource(
|
||||
platform=Platform.RELAY,
|
||||
chat_id=chat_id,
|
||||
chat_type="channel",
|
||||
scope_id=scope_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_reattaches_dm_user_id_from_inbound_scope():
|
||||
"""A DM reply has no scope_id, so the connector resolves the tenant from the
|
||||
recipient's author binding — it needs metadata.user_id. The adapter must
|
||||
re-attach the authentic author id learned from the inbound DM. Regression for
|
||||
live 'discord egress declined: target not routed to an onboarded tenant' on
|
||||
DM replies (the connector-side fix is gateway-gateway #67)."""
|
||||
t = _CaptureTransport()
|
||||
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
|
||||
a._capture_scope(_make_dm_event(chat_id="dm-1", user_id="user-42"))
|
||||
|
||||
await a.send("dm-1", "the reply")
|
||||
|
||||
assert t.sent["metadata"].get("user_id") == "user-42"
|
||||
# A DM carries no scope_id — only the author discriminator.
|
||||
assert "scope_id" not in t.sent["metadata"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scoped_reply_reattaches_both_scope_id_and_user_id():
|
||||
"""A scoped (guild) reply now re-attaches BOTH scope_id AND the authentic
|
||||
author user_id. scope_id is the connector's primary discriminator; user_id
|
||||
is the author-first FALLBACK the connector uses when the guild has no route
|
||||
row (a managed agent joins guilds dynamically, so a provision-time guild
|
||||
route is not guaranteed). Regression for live 'discord egress declined:
|
||||
target not routed to an onboarded tenant' on GUILD replies (paired with
|
||||
gateway-gateway makeDiscordTenantOf guild-route-miss fallback)."""
|
||||
t = _CaptureTransport()
|
||||
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
|
||||
a._capture_scope(
|
||||
_make_scoped_event_with_author(
|
||||
chat_id="chan-1", scope_id="scope-9", user_id="user-42"
|
||||
)
|
||||
)
|
||||
await a.send("chan-1", "hi")
|
||||
assert t.sent["metadata"].get("scope_id") == "scope-9"
|
||||
assert t.sent["metadata"].get("user_id") == "user-42"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_forwards_explicit_clear_with_routing_context():
|
||||
t = _CaptureTransport()
|
||||
a = RelayAdapter(PlatformConfig(), make_desc(platform="slack"), transport=t)
|
||||
event = _make_event(chat_id="channel-1", scope_id="workspace-1")
|
||||
event.source.platform = Platform.SLACK
|
||||
a._capture_scope(event)
|
||||
|
||||
await a.stop_typing("channel-1", metadata={"thread_id": "thread-1"})
|
||||
|
||||
assert t.sent == {
|
||||
"op": "typing",
|
||||
"chat_id": "channel-1",
|
||||
"content": "",
|
||||
"metadata": {
|
||||
"thread_id": "thread-1",
|
||||
"scope_id": "workspace-1",
|
||||
},
|
||||
}
|
||||
assert t.sent_platform == "slack"
|
||||
|
||||
|
||||
# ── typing indicator over the relay (op="typing") ────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_typing_tags_egress_platform():
|
||||
"""Phase 1.5: a multi-platform gateway must egress typing through the
|
||||
platform the chat lives on, exactly like send() — the underlying platform
|
||||
learned from the inbound event tags the frame."""
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.session import SessionSource
|
||||
|
||||
t = _CaptureTransport()
|
||||
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
|
||||
src = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="chan-2",
|
||||
chat_type="channel",
|
||||
scope_id="scope-1",
|
||||
)
|
||||
a._capture_scope(MessageEvent(text="hi", source=src, message_type=MessageType.TEXT))
|
||||
|
||||
await a.send_typing("chan-2")
|
||||
|
||||
assert t.sent_platform == "discord"
|
||||
|
||||
|
||||
# ── Phase 7 Unit 7d-B: terminal auth revocation → clean "relay disabled" ─────
|
||||
|
||||
|
||||
class _RevokedTransport:
|
||||
"""Transport stand-in that reports a terminal auth revocation (the
|
||||
production WebSocketRelayTransport latches this after a 4401 close that
|
||||
follows a successful handshake)."""
|
||||
|
||||
def __init__(self):
|
||||
self.auth_revoked = True
|
||||
|
||||
def set_inbound_handler(self, h): # noqa: D401
|
||||
self._h = h
|
||||
|
||||
|
||||
# ─────────────── get_chat_info gated on supported_ops (Phase 1) ───────────────
|
||||
|
||||
|
||||
class _ChatInfoTransport:
|
||||
"""Transport stub that records whether get_chat_info was proxied."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def set_inbound_handler(self, h): # noqa: D401
|
||||
self._h = h
|
||||
|
||||
async def get_chat_info(self, chat_id):
|
||||
self.calls.append(chat_id)
|
||||
return {"name": "general", "type": "channel"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_chat_info_local_fallback_when_not_advertised():
|
||||
"""A connector that advertises ops but OMITS get_chat_info is authoritative."""
|
||||
t = _ChatInfoTransport()
|
||||
a = RelayAdapter(
|
||||
PlatformConfig(),
|
||||
make_desc(supported_ops=("send", "edit", "typing")),
|
||||
transport=t,
|
||||
)
|
||||
info = await a.get_chat_info("chan-1")
|
||||
assert info == {"name": "chan-1", "type": "dm"}
|
||||
assert t.calls == []
|
||||
|
||||
|
||||
class _HangOnIdleTransport:
|
||||
"""Transport that hangs in go_idle so outer disconnect cancellation can race it."""
|
||||
|
||||
def __init__(self):
|
||||
self.go_idle_started = asyncio.Event()
|
||||
self.go_idle_timeouts: list[float] = []
|
||||
self.disconnect_calls = 0
|
||||
|
||||
def set_inbound_handler(self, h): # noqa: D401
|
||||
self._h = h
|
||||
|
||||
async def go_idle(self, timeout_s: float = 10.0):
|
||||
self.go_idle_timeouts.append(timeout_s)
|
||||
self.go_idle_started.set()
|
||||
await asyncio.sleep(3600)
|
||||
return False
|
||||
|
||||
async def disconnect(self):
|
||||
self.disconnect_calls += 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_tears_down_transport_when_go_idle_is_cancelled():
|
||||
"""Runner disconnect budgets can cancel adapter.disconnect mid go_idle.
|
||||
|
||||
The gateway runner's default adapter disconnect budget is 5s, while
|
||||
transport.go_idle defaults to 10s. If cancellation lands during the idle
|
||||
handshake, transport.disconnect must still run so the websocket/supervisor
|
||||
cannot outlive the adapter.
|
||||
"""
|
||||
transport = _HangOnIdleTransport()
|
||||
adapter = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=transport)
|
||||
|
||||
task = asyncio.create_task(adapter.disconnect())
|
||||
await asyncio.wait_for(transport.go_idle_started.wait(), timeout=1.0)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert transport.disconnect_calls == 1
|
||||
assert transport.go_idle_timeouts
|
||||
assert transport.go_idle_timeouts[0] < 5.0
|
||||
Reference in New Issue
Block a user