This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/tests/test_slack_live.py
Adam Moussa 9bfb5f1534 feat(agent-team): 👍-acknowledge received Slack answers (reactions:write)
WS Slack-UX Feature 2. When the inbound listener acts on an answer in a task
thread, it adds a 👍 reaction to that reply so the human sees the machine
received it.

- SlackListener gains an optional reactor seam; handle_event reacts to the
  inbound reply message (channel + event ts) AFTER the AUTHZ-01 owner check
  passes — a non-owner message is rejected and never reacted to. Best-effort:
  any reaction failure (notably a missing scope) is swallowed and never breaks
  handle_event or the listen loop.
- /new-task is NOT reacted to (a slash command has no reactable message); its
  "📥 Task received" root post is the acknowledgement.
- build_slack_reactor wraps WebClient.reactions_add(name="thumbsup"); the
  default listener factory wires it best-effort from SLACK_BOT_TOKEN.
- Adds reactions:write to the bot scopes in agent-team-manifest.json.

NOTE: the new reactions:write scope requires Adam to re-apply the manifest to
app A0BCC7TTU66 and reinstall the app. Until then reactions.add returns
missing_scope, which the listener swallows (the reaction silently no-ops) —
answer handling is unaffected.

AUTHZ-01 and the first-answer-wins CAS remain unchanged.
2026-06-23 15:49:40 -04:00

318 lines
11 KiB
Python

"""Unit tests for agent_team.transport.slack_live (§3.3.1, §7.1 P1).
The live poster is the production ``slack_sdk`` backing for the §3.3.1 injected
``SlackPoster`` seam. These tests prove the contract entirely with mocks (no
network, and ``slack_sdk`` itself is never required): the poster maps the
adapter's message dict to the ``chat.postMessage`` parameters Slack accepts,
the ``ts`` round-trips as the ``channel_ref`` through a real ``SlackTransport``,
``callback_id`` is dropped (not a valid postMessage param) while ``metadata``
carries the durable ``question_id``, and a missing package / token or a client
failure fails loudly.
"""
from __future__ import annotations
import importlib
from typing import Any
import pytest
from agent_team.transport.base import QuestionSet
from agent_team.transport.slack_adapter import SlackPostError, SlackTransport
from agent_team.transport.slack_live import (
build_live_slack_transport,
build_slack_poster,
)
# --------------------------------------------------------------------------- #
# Test doubles #
# --------------------------------------------------------------------------- #
class _FakeWebClient:
"""A fake ``slack_sdk.WebClient`` recording ``chat_postMessage`` kwargs."""
def __init__(self, response: dict[str, Any] | None = None) -> None:
self.response = (
response if response is not None else {"ts": "169.1", "ok": True}
)
self.calls: list[dict[str, Any]] = []
self.reaction_calls: list[dict[str, Any]] = []
def chat_postMessage(self, **kwargs: Any) -> dict[str, Any]:
self.calls.append(kwargs)
return self.response
def reactions_add(self, **kwargs: Any) -> dict[str, Any]:
self.reaction_calls.append(kwargs)
return {"ok": True}
class _DataResponse:
"""A ``slack_sdk.SlackResponse``-like object exposing the payload via ``.data``."""
def __init__(self, data: dict[str, Any]) -> None:
self.data = data
class _SlackApiErrorLike(Exception):
"""Stands in for ``slack_sdk.errors.SlackApiError`` (no slack_sdk needed)."""
class _FailingClient:
"""A fake client whose ``chat_postMessage`` raises a Slack-API-like error."""
def chat_postMessage(self, **kwargs: Any) -> dict[str, Any]:
raise _SlackApiErrorLike("the_dog_ate_it")
def _question_set() -> QuestionSet:
return QuestionSet(
thread_id="task-7",
question_id="q-42",
turn=1,
questions=["Ship it?"],
context={"repo": "agent-team"},
)
# --------------------------------------------------------------------------- #
# Clean import without slack_sdk #
# --------------------------------------------------------------------------- #
def test_module_imports_without_slack_sdk(monkeypatch: pytest.MonkeyPatch) -> None:
"""The module imports cleanly even when ``slack_sdk`` cannot be imported."""
import builtins
real_import = builtins.__import__
def _blocked_import(name: str, *args: Any, **kwargs: Any) -> Any:
if name == "slack_sdk" or name.startswith("slack_sdk."):
raise ImportError("slack_sdk is blocked for this test")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _blocked_import)
module = importlib.reload(
importlib.import_module("agent_team.transport.slack_live")
)
assert hasattr(module, "build_slack_poster")
assert hasattr(module, "build_live_slack_transport")
# --------------------------------------------------------------------------- #
# Happy path: injected fake client #
# --------------------------------------------------------------------------- #
def test_poster_returns_mapping_with_ts() -> None:
"""The poster forwards to the client and returns a mapping carrying ``ts``."""
client = _FakeWebClient()
poster = build_slack_poster(client=client)
result = poster(
{
"channel": "C123",
"callback_id": "shq:q-42",
"text": "hi",
"blocks": [],
"metadata": {"event_type": "agent_team_question"},
}
)
assert result["ts"] == "169.1"
def test_post_question_round_trips_ts_as_channel_ref() -> None:
"""Wired through a real ``SlackTransport``, ``ts`` becomes the channel_ref."""
client = _FakeWebClient()
transport = SlackTransport("C123", poster=build_slack_poster(client=client))
channel_ref = transport.post_question(
thread_id="task-7",
question_id="q-42",
turn=1,
question_set=_question_set(),
deadline="2026-06-18T00:00:00Z",
)
assert channel_ref == "169.1"
def test_callback_id_dropped_metadata_carries_question_id() -> None:
"""``callback_id`` is not sent; ``metadata`` carries the durable question_id."""
client = _FakeWebClient()
transport = SlackTransport("C123", poster=build_slack_poster(client=client))
transport.post_question(
thread_id="task-7",
question_id="q-42",
turn=1,
question_set=_question_set(),
deadline="2026-06-18T00:00:00Z",
)
assert len(client.calls) == 1
kwargs = client.calls[0]
# callback_id is NOT a valid chat.postMessage parameter and must be dropped.
assert "callback_id" not in kwargs
# metadata IS forwarded and carries the durable inbound question_id.
assert kwargs["metadata"]["event_payload"]["question_id"] == "q-42"
# the accepted parameters are forwarded.
assert kwargs["channel"] == "C123"
assert "text" in kwargs
assert "blocks" in kwargs
def test_poster_forwards_thread_ts_to_chat_post_message() -> None:
"""One-thread-per-task: thread_ts IS a postMessage param and is forwarded."""
from agent_team.transport.slack_live import build_slack_poster as _bp
client = _FakeWebClient()
transport = SlackTransport("C123", poster=_bp(client=client))
transport.post_question(
thread_id="task-7",
question_id="q-42",
turn=1,
question_set=_question_set(),
deadline="2026-06-18T00:00:00Z",
thread_ts="1700000000.ROOT",
)
assert client.calls[0]["thread_ts"] == "1700000000.ROOT"
def test_reactor_calls_reactions_add_with_thumbsup() -> None:
"""build_slack_reactor performs reactions.add(channel, ts, name='thumbsup')."""
from agent_team.transport.slack_live import build_slack_reactor
client = _FakeWebClient()
reactor = build_slack_reactor(client=client)
reactor("C123", "1700000001.000200")
assert client.reaction_calls == [
{"channel": "C123", "timestamp": "1700000001.000200", "name": "thumbsup"}
]
def test_reactor_missing_token_raises_runtime_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No token + no client => the same loud RuntimeError as the poster path."""
from agent_team.transport.slack_live import build_slack_reactor
monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False)
with pytest.raises(RuntimeError):
build_slack_reactor()
def test_convenience_transport_factory() -> None:
"""``build_live_slack_transport`` wires the live poster onto a transport."""
client = _FakeWebClient()
transport = build_live_slack_transport("C123", client=client)
channel_ref = transport.post_question(
thread_id="task-7",
question_id="q-42",
turn=1,
question_set=_question_set(),
deadline="2026-06-18T00:00:00Z",
)
assert channel_ref == "169.1"
def test_poster_accepts_slack_response_with_data_attr() -> None:
"""A ``SlackResponse``-like object is coerced via its ``.data`` mapping."""
client = _FakeWebClient(response=None)
client.response = _DataResponse({"ts": "169.1", "ok": True}) # type: ignore[assignment]
poster = build_slack_poster(client=client)
result = poster({"channel": "C123", "text": "hi"})
assert result["ts"] == "169.1"
class _UnsupportedResponseClient:
"""A fake client returning neither a mapping nor an object with ``.data``."""
def chat_postMessage(self, **kwargs: Any) -> Any:
return object()
def test_poster_unsupported_response_raises_type_error() -> None:
"""A response that is neither a mapping nor has a mapping ``.data`` is fatal.
Exercises the ``_as_mapping`` guard: a bare object (no ``ts``, no ``.data``)
cannot yield a ``channel_ref``, so the poster raises ``TypeError`` with the
guard's "unsupported response" message rather than silently dropping the ts.
"""
poster = build_slack_poster(client=_UnsupportedResponseClient())
with pytest.raises(TypeError, match="unsupported response"):
poster({"channel": "C123", "text": "hi"})
# --------------------------------------------------------------------------- #
# Failure modes #
# --------------------------------------------------------------------------- #
def test_missing_token_raises_runtime_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""No token and no SLACK_BOT_TOKEN raises a clear RuntimeError.
Stub ``slack_sdk`` into ``sys.modules`` so the deferred import SUCCEEDS and
the no-token branch is what's under test. Without this the test is
environment-dependent: where slack_sdk is absent (CI) the import-missing
error fires first and masks the token check (local-vs-CI drift).
"""
import sys
from types import ModuleType
fake = ModuleType("slack_sdk")
fake.WebClient = lambda **kw: None # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "slack_sdk", fake)
monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False)
with pytest.raises(RuntimeError, match="Slack bot token"):
build_slack_poster()
def test_missing_package_raises_runtime_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A missing ``slack_sdk`` package raises a clear RuntimeError."""
import builtins
real_import = builtins.__import__
def _blocked_import(name: str, *args: Any, **kwargs: Any) -> Any:
if name == "slack_sdk" or name.startswith("slack_sdk."):
raise ImportError("slack_sdk is blocked for this test")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _blocked_import)
monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-present")
with pytest.raises(RuntimeError, match="slack_sdk is unavailable"):
build_slack_poster()
def test_client_failure_surfaces_as_slack_post_error() -> None:
"""A SlackApiError-like client failure surfaces as ``SlackPostError``."""
transport = SlackTransport(
"C123", poster=build_slack_poster(client=_FailingClient())
)
with pytest.raises(SlackPostError):
transport.post_question(
thread_id="task-7",
question_id="q-42",
turn=1,
question_set=_question_set(),
deadline="2026-06-18T00:00:00Z",
)