"""Unit tests for agent_team.transport.slack_adapter (§3.3.1, §7.1 P1). The Slack adapter is the first concrete ``Transport``. These tests prove the two §3.3.1 contracts in isolation (no network): ``post_question`` embeds the ``question_id`` and returns the message ``ts`` as ``channel_ref``, and ``parse_answer`` normalizes the inbound Slack shapes to ``(question_id, answer, via)`` for the first-answer-wins compare-and-set. """ from __future__ import annotations from typing import Any import pytest from agent_team.transport.base import QuestionSet, Transport from agent_team.transport.slack_adapter import ( CALLBACK_ID_PREFIX, VIA_SLACK, SlackPostError, SlackTransport, build_callback_id, build_question_blocks, parse_callback_id, ) # --------------------------------------------------------------------------- # # Test doubles # # --------------------------------------------------------------------------- # class _RecordingPoster: """A poster that records the message and returns a canned Slack response.""" def __init__(self, response: dict[str, Any] | None = None) -> None: self.response = ( response if response is not None else {"ok": True, "ts": "1700000000.000100"} ) self.calls: list[dict[str, Any]] = [] def __call__(self, message: dict[str, Any]) -> dict[str, Any]: self.calls.append(message) return self.response def _question_set(**overrides: Any) -> QuestionSet: defaults: dict[str, Any] = { "thread_id": "thread-1", "question_id": "q-abc", "turn": 0, "questions": ["Which branch?", "Bump major?"], "context": {"repo": "sea-haven/foo", "summary": "dep bump"}, } defaults.update(overrides) return QuestionSet(**defaults) # --------------------------------------------------------------------------- # # Contract / typing # # --------------------------------------------------------------------------- # def test_slack_transport_is_a_transport_subclass() -> None: assert issubclass(SlackTransport, Transport) def test_slack_transport_is_instantiable_and_concrete() -> None: # Concrete: implements both abstractmethods, so construction must succeed. t = SlackTransport(channel="C123", poster=_RecordingPoster()) assert isinstance(t, Transport) # --------------------------------------------------------------------------- # # callback_id helpers # # --------------------------------------------------------------------------- # def test_build_callback_id_embeds_question_id() -> None: cb = build_callback_id("q-abc") assert cb == f"{CALLBACK_ID_PREFIX}:q-abc" assert "q-abc" in cb def test_parse_callback_id_roundtrips() -> None: assert parse_callback_id(build_callback_id("q-xyz")) == "q-xyz" def test_parse_callback_id_accepts_bare_id() -> None: assert parse_callback_id("q-bare") == "q-bare" def test_parse_callback_id_rejects_empty() -> None: with pytest.raises(ValueError): parse_callback_id("") def test_parse_callback_id_rejects_prefix_only() -> None: with pytest.raises(ValueError): parse_callback_id(f"{CALLBACK_ID_PREFIX}:") # --------------------------------------------------------------------------- # # build_question_blocks # # --------------------------------------------------------------------------- # def test_build_question_blocks_lists_every_question() -> None: qs = _question_set(questions=["A?", "B?", "C?"]) blocks = build_question_blocks(qs, deadline="2026-06-18T00:00:00Z") section_texts = [b["text"]["text"] for b in blocks if b["type"] == "section"] assert len(section_texts) == 3 assert any("A?" in t for t in section_texts) assert any("C?" in t for t in section_texts) def test_build_question_blocks_surfaces_context_and_deadline() -> None: qs = _question_set() blocks = build_question_blocks(qs, deadline="2026-06-18T00:00:00Z") flat = repr(blocks) assert "sea-haven/foo" in flat assert "dep bump" in flat assert "2026-06-18T00:00:00Z" in flat def test_build_question_blocks_omits_empty_context() -> None: qs = _question_set(context={}) blocks = build_question_blocks(qs, deadline="2026-06-18T00:00:00Z") # No repo/summary context block beyond the trailing deadline context block. context_blocks = [b for b in blocks if b["type"] == "context"] assert len(context_blocks) == 1 # only the deadline footer # --------------------------------------------------------------------------- # # post_question # # --------------------------------------------------------------------------- # def test_post_question_returns_message_ts_as_channel_ref() -> None: poster = _RecordingPoster({"ok": True, "ts": "1700000000.000200"}) t = SlackTransport(channel="C999", poster=poster) ref = t.post_question( thread_id="thread-1", question_id="q-abc", turn=2, question_set=_question_set(turn=2), deadline="2026-06-18T00:00:00Z", ) assert ref == "1700000000.000200" def test_post_question_embeds_question_id_in_callback_id() -> None: poster = _RecordingPoster() t = SlackTransport(channel="C999", poster=poster) t.post_question( thread_id="thread-1", question_id="q-abc", turn=0, question_set=_question_set(), deadline="2026-06-18T00:00:00Z", ) sent = poster.calls[0] assert sent["callback_id"] == build_callback_id("q-abc") assert sent["channel"] == "C999" # The metadata payload also carries the identity for restart recovery. assert sent["metadata"]["event_payload"]["question_id"] == "q-abc" assert sent["metadata"]["event_payload"]["thread_id"] == "thread-1" def test_post_question_accepts_nested_message_ts() -> None: poster = _RecordingPoster({"ok": True, "message": {"ts": "1700000000.000300"}}) t = SlackTransport(channel="C1", poster=poster) ref = t.post_question( thread_id="t", question_id="q", turn=0, question_set=_question_set(), deadline="d", ) assert ref == "1700000000.000300" def test_post_question_raises_when_response_missing_ts() -> None: poster = _RecordingPoster({"ok": True}) # no ts -> cannot record channel_ref t = SlackTransport(channel="C1", poster=poster) with pytest.raises(SlackPostError): t.post_question( thread_id="t", question_id="q", turn=0, question_set=_question_set(), deadline="d", ) def test_post_question_default_poster_refuses_network() -> None: # Foundation ships nothing live: a poster-less transport must not post. t = SlackTransport(channel="C1") with pytest.raises(SlackPostError): t.post_question( thread_id="t", question_id="q", turn=0, question_set=_question_set(), deadline="d", ) def test_post_question_normalizes_poster_exception_to_slack_post_error() -> None: def _boom(_message: dict[str, Any]) -> dict[str, Any]: raise RuntimeError("connection reset") t = SlackTransport(channel="C1", poster=_boom) with pytest.raises(SlackPostError) as exc: t.post_question( thread_id="t", question_id="q", turn=0, question_set=_question_set(), deadline="d", ) assert "connection reset" in str(exc.value) # --------------------------------------------------------------------------- # # parse_answer # # --------------------------------------------------------------------------- # def test_parse_answer_button_click() -> None: t = SlackTransport(channel="C1", poster=_RecordingPoster()) payload = { "type": "block_actions", "callback_id": build_callback_id("q-abc"), "actions": [{"action_id": "approve", "value": "yes"}], } assert t.parse_answer(payload) == ("q-abc", "yes", VIA_SLACK) def test_parse_answer_select_menu() -> None: t = SlackTransport(channel="C1", poster=_RecordingPoster()) payload = { "callback_id": build_callback_id("q-sel"), "actions": [{"action_id": "branch", "selected_option": {"value": "main"}}], } assert t.parse_answer(payload) == ("q-sel", "main", VIA_SLACK) def test_parse_answer_multi_select_returns_list() -> None: t = SlackTransport(channel="C1", poster=_RecordingPoster()) payload = { "callback_id": build_callback_id("q-multi"), "actions": [ { "action_id": "labels", "selected_options": [{"value": "bug"}, {"value": "ci"}], } ], } qid, answer, via = t.parse_answer(payload) assert qid == "q-multi" assert answer == ["bug", "ci"] assert via == VIA_SLACK def test_parse_answer_multiple_actions_returns_list() -> None: t = SlackTransport(channel="C1", poster=_RecordingPoster()) payload = { "callback_id": build_callback_id("q-two"), "actions": [ {"action_id": "a", "value": "1"}, {"action_id": "b", "value": "2"}, ], } _, answer, _ = t.parse_answer(payload) assert answer == ["1", "2"] def test_parse_answer_plain_text_reply() -> None: t = SlackTransport(channel="C1", poster=_RecordingPoster()) payload = { "callback_id": build_callback_id("q-text"), "text": "use the release branch", } assert t.parse_answer(payload) == ( "q-text", "use the release branch", VIA_SLACK, ) def test_parse_answer_uses_metadata_event_payload() -> None: # No top-level callback_id; the question_id rides in message metadata. t = SlackTransport(channel="C1", poster=_RecordingPoster()) payload = { "message": { "metadata": { "event_type": "agent_team_question", "event_payload": {"question_id": "q-meta", "thread_id": "t"}, } }, "answer": "ok", } assert t.parse_answer(payload) == ("q-meta", "ok", VIA_SLACK) def test_parse_answer_bare_question_id_field() -> None: t = SlackTransport(channel="C1", poster=_RecordingPoster()) payload = {"question_id": "q-bare", "answer": 42} assert t.parse_answer(payload) == ("q-bare", 42, VIA_SLACK) def test_parse_answer_rejects_non_mapping() -> None: t = SlackTransport(channel="C1", poster=_RecordingPoster()) with pytest.raises(ValueError): t.parse_answer("not a payload") def test_parse_answer_rejects_missing_question_id() -> None: t = SlackTransport(channel="C1", poster=_RecordingPoster()) with pytest.raises(ValueError): t.parse_answer({"text": "an answer with no id"}) def test_parse_answer_rejects_missing_answer() -> None: t = SlackTransport(channel="C1", poster=_RecordingPoster()) with pytest.raises(ValueError): t.parse_answer({"callback_id": build_callback_id("q-noans")}) # --------------------------------------------------------------------------- # # round-trip: post then parse # # --------------------------------------------------------------------------- # def test_post_then_parse_round_trips_question_id() -> None: poster = _RecordingPoster() t = SlackTransport(channel="C1", poster=poster) t.post_question( thread_id="thread-9", question_id="q-round", turn=1, question_set=_question_set(question_id="q-round", turn=1), deadline="2026-06-18T00:00:00Z", ) sent_callback_id = poster.calls[0]["callback_id"] # Simulate Slack echoing the message-level callback_id on an interaction. inbound = { "callback_id": sent_callback_id, "actions": [{"action_id": "approve", "value": "approved"}], } qid, answer, via = t.parse_answer(inbound) assert qid == "q-round" assert answer == "approved" assert via == VIA_SLACK