Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
"""Kanban wake events must key to the same session as inbound messages.
|
||||
|
||||
Slack session keys include the workspace id, so the wake source the notifier
|
||||
rebuilds from a subscription row must carry it too. The contract asserted here:
|
||||
the key built from the wake source byte-matches the key built from an inbound
|
||||
source for the same conversation, a scope-less key does not, and platforms
|
||||
without tenant scoping keep their exact key shape.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.kanban_watchers import _wake_scope_id
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import build_session_key
|
||||
from hermes_cli import kanban_db as kb
|
||||
from plugins.platforms.slack.adapter import SlackAdapter
|
||||
|
||||
TEAM = "T0B8U2M6NRE"
|
||||
CHANNEL = "C0BCDG3H66P"
|
||||
THREAD = "1720000000.000100"
|
||||
USER = "U0BCE4NRVKN"
|
||||
|
||||
|
||||
class UnscopedAdapter:
|
||||
"""Push-capable adapter for a platform without tenant scoping."""
|
||||
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
self.handled = []
|
||||
|
||||
async def send(self, chat_id, text, metadata=None):
|
||||
self.sent.append({"chat_id": chat_id, "text": text, "metadata": metadata or {}})
|
||||
|
||||
async def handle_message(self, event):
|
||||
self.handled.append(event)
|
||||
|
||||
|
||||
def _slack_adapter(channel_team=None):
|
||||
"""Real SlackAdapter with only its I/O stubbed."""
|
||||
adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake-token"))
|
||||
adapter._app = MagicMock()
|
||||
adapter._app.client = AsyncMock()
|
||||
adapter._running = True
|
||||
adapter.send = AsyncMock()
|
||||
adapter.handle_message = AsyncMock()
|
||||
if channel_team:
|
||||
adapter._channel_team.update(channel_team)
|
||||
return adapter
|
||||
|
||||
|
||||
def _runner(adapter, platform=Platform.SLACK):
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._running = True
|
||||
runner.adapters = {platform: adapter}
|
||||
runner._kanban_sub_fail_counts = {}
|
||||
# A gateway whose dispatcher owns the singleton lock.
|
||||
runner._kanban_dispatcher_lock_handle = object()
|
||||
return runner
|
||||
|
||||
|
||||
async def _one_notifier_tick(monkeypatch, runner):
|
||||
real_sleep = asyncio.sleep
|
||||
|
||||
async def fake_sleep(delay):
|
||||
if delay == 5:
|
||||
return None
|
||||
runner._running = False
|
||||
await real_sleep(0)
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
await runner._kanban_notifier_watcher(interval=1)
|
||||
|
||||
|
||||
def _completed_subscription(**sub_kwargs):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(
|
||||
conn,
|
||||
title="wake scope",
|
||||
assignee="worker",
|
||||
session_id="origin-session",
|
||||
)
|
||||
# Push-adapter wake injection is gated on the subscription's
|
||||
# delivery_mode ("notify+wake"/"wake") on current main; the plain
|
||||
# "notify" default would never reach the wake path under test.
|
||||
sub_kwargs.setdefault("delivery_mode", "notify+wake")
|
||||
kb.add_notify_sub(conn, task_id=tid, **sub_kwargs)
|
||||
kb.complete_task(conn, tid, summary="done")
|
||||
return tid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _wake_source_from(adapter):
|
||||
assert adapter.handle_message.await_count == 1, (
|
||||
f"expected exactly one wake injection, got {adapter.handle_message.await_count}"
|
||||
)
|
||||
return adapter.handle_message.await_args.args[0].source
|
||||
|
||||
|
||||
def test_slack_wake_resumes_the_creators_workspace_scoped_session(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "wake-scope.db"))
|
||||
kb.init_db()
|
||||
_completed_subscription(
|
||||
platform="slack",
|
||||
chat_id=CHANNEL,
|
||||
chat_type="group",
|
||||
thread_id=THREAD,
|
||||
user_id=USER,
|
||||
# Slack sources are stamped with slack_team_id when subscribing.
|
||||
delivery_metadata={"slack_team_id": TEAM, "thread_id": THREAD},
|
||||
)
|
||||
|
||||
adapter = _slack_adapter()
|
||||
asyncio.run(_one_notifier_tick(monkeypatch, _runner(adapter)))
|
||||
|
||||
wake = _wake_source_from(adapter)
|
||||
assert wake.scope_id == TEAM
|
||||
|
||||
inbound = adapter.build_source(
|
||||
chat_id=CHANNEL,
|
||||
chat_type="group",
|
||||
user_id=USER,
|
||||
thread_id=THREAD,
|
||||
scope_id=TEAM,
|
||||
)
|
||||
wake_key = build_session_key(wake)
|
||||
assert wake_key == build_session_key(inbound)
|
||||
assert TEAM in wake_key
|
||||
# A scope-less source keys to a different session for the same chat.
|
||||
assert build_session_key(replace(wake, scope_id=None, guild_id=None)) != wake_key
|
||||
|
||||
|
||||
def test_slack_wake_falls_back_to_the_adapter_channel_workspace_map(tmp_path, monkeypatch):
|
||||
"""Subscriptions that stored no workspace resolve it from the adapter."""
|
||||
monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "wake-scope-fallback.db"))
|
||||
kb.init_db()
|
||||
_completed_subscription(
|
||||
platform="slack",
|
||||
chat_id=CHANNEL,
|
||||
chat_type="group",
|
||||
thread_id=THREAD,
|
||||
delivery_metadata={"thread_id": THREAD, "chat_type": "group"},
|
||||
)
|
||||
|
||||
adapter = _slack_adapter(channel_team={CHANNEL: TEAM})
|
||||
asyncio.run(_one_notifier_tick(monkeypatch, _runner(adapter)))
|
||||
|
||||
wake = _wake_source_from(adapter)
|
||||
assert wake.scope_id == TEAM
|
||||
assert TEAM in build_session_key(wake)
|
||||
|
||||
|
||||
def test_unknown_channel_keeps_the_previous_unscoped_wake(tmp_path, monkeypatch):
|
||||
"""An unresolvable workspace yields an unscoped key, not a wrong scope."""
|
||||
monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "wake-scope-unknown.db"))
|
||||
kb.init_db()
|
||||
_completed_subscription(
|
||||
platform="slack",
|
||||
chat_id=CHANNEL,
|
||||
chat_type="group",
|
||||
)
|
||||
|
||||
adapter = _slack_adapter()
|
||||
asyncio.run(_one_notifier_tick(monkeypatch, _runner(adapter)))
|
||||
|
||||
wake = _wake_source_from(adapter)
|
||||
assert wake.scope_id is None
|
||||
assert build_session_key(wake) == f"agent:main:slack:group:{CHANNEL}"
|
||||
|
||||
|
||||
def test_unscoped_platform_wake_key_is_byte_identical(tmp_path, monkeypatch):
|
||||
"""Platforms without tenant scoping must keep their exact key shape."""
|
||||
monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "wake-scope-telegram.db"))
|
||||
kb.init_db()
|
||||
_completed_subscription(
|
||||
platform="telegram",
|
||||
chat_id="chat-dm",
|
||||
chat_type="dm",
|
||||
)
|
||||
|
||||
adapter = UnscopedAdapter()
|
||||
asyncio.run(_one_notifier_tick(monkeypatch, _runner(adapter, Platform.TELEGRAM)))
|
||||
|
||||
assert len(adapter.handled) == 1
|
||||
wake = adapter.handled[0].source
|
||||
assert wake.scope_id is None
|
||||
assert build_session_key(wake) == "agent:main:telegram:dm:chat-dm"
|
||||
|
||||
|
||||
def test_wake_scope_id_prefers_persisted_metadata_over_the_adapter_map():
|
||||
"""Persisted metadata wins; the adapter map is only a fallback."""
|
||||
adapter = SlackAdapter.__new__(SlackAdapter)
|
||||
adapter._channel_team = {CHANNEL: "T_STALE"}
|
||||
|
||||
assert _wake_scope_id(
|
||||
adapter, {"chat_id": CHANNEL, "delivery_metadata": {"slack_team_id": TEAM}}
|
||||
) == TEAM
|
||||
assert _wake_scope_id(adapter, {"chat_id": CHANNEL}) == "T_STALE"
|
||||
assert _wake_scope_id(adapter, {"chat_id": "C_OTHER"}) is None
|
||||
|
||||
|
||||
def test_wake_scope_id_degrades_when_the_adapter_lookup_raises():
|
||||
class Exploding:
|
||||
def scope_id_for_chat(self, chat_id):
|
||||
raise RuntimeError("adapter state gone")
|
||||
|
||||
assert _wake_scope_id(Exploding(), {"chat_id": CHANNEL}) is None
|
||||
|
||||
|
||||
def test_wake_scope_id_is_none_for_adapters_without_the_hook():
|
||||
"""Adapters that don't resolve scopes leave the wake unscoped."""
|
||||
assert _wake_scope_id(UnscopedAdapter(), {"chat_id": CHANNEL}) is None
|
||||
|
||||
|
||||
def test_slack_adapter_reports_the_channel_workspace():
|
||||
adapter = SlackAdapter.__new__(SlackAdapter)
|
||||
adapter._channel_team = {CHANNEL: TEAM}
|
||||
|
||||
assert adapter.scope_id_for_chat(CHANNEL) == TEAM
|
||||
assert adapter.scope_id_for_chat("C_UNKNOWN") is None
|
||||
assert adapter.scope_id_for_chat("") is None
|
||||
|
||||
|
||||
def test_slack_adapter_reports_no_scope_for_ambiguous_channels():
|
||||
"""A channel claimed by two workspaces resolves to no scope."""
|
||||
adapter = _slack_adapter()
|
||||
adapter._remember_channel_team("D_SHARED", "T_ONE")
|
||||
adapter._remember_channel_team("D_SHARED", "T_TWO")
|
||||
|
||||
assert adapter.scope_id_for_chat("D_SHARED") is None
|
||||
Reference in New Issue
Block a user