"""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, PLAN_DECISION_ABANDON_ACTION, PLAN_DECISION_APPROVE_ACTION, PLAN_DECISION_KIND, PLAN_DECISION_REQUEST_CHANGES_ACTION, VIA_SLACK, SlackPostError, SlackTransport, build_callback_id, build_plan_decision_blocks, build_question_blocks, build_request_changes_modal, map_plan_decision, 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_threads_under_thread_ts_when_given() -> None: """One-thread-per-task: a non-empty thread_ts is forwarded as message['thread_ts']. The poster receives ``thread_ts`` so Slack posts the question as a threaded reply under the task's root "๐Ÿ“ฅ Task received" message. """ 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", thread_ts="1700000000.ROOT", ) assert poster.calls[0]["thread_ts"] == "1700000000.ROOT" def test_post_question_omits_thread_ts_by_default() -> None: """No thread_ts (the default) => top-level post (no 'thread_ts' key).""" 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", ) assert "thread_ts" not in poster.calls[0] def test_poster_property_exposes_injected_poster() -> None: """The transport exposes its poster so the listener can post the root ack.""" poster = _RecordingPoster() t = SlackTransport(channel="C1", poster=poster) assert t.poster is poster 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 # --------------------------------------------------------------------------- # # B3 โ€” kind-aware plan-decision normalization (map_plan_decision) # # --------------------------------------------------------------------------- # @pytest.mark.parametrize( "raw", ["approve", "approved", "yes", "ok", "lgtm", "ship", "APPROVE", " Yes "], ) def test_map_plan_decision_approve_verbs(raw: str) -> None: """All approve-allowlist verbs map to approve (case + whitespace insensitive).""" result = map_plan_decision(raw) assert result == {"decision": "approve", "notes": ""} @pytest.mark.parametrize( "raw", ["abandon", "reject", "cancel", "stop", "kill", "ABANDON", " Cancel "], ) def test_map_plan_decision_abandon_verbs(raw: str) -> None: """All abandon-allowlist verbs map to abandon (case + whitespace insensitive).""" result = map_plan_decision(raw) assert result == {"decision": "abandon", "notes": ""} def test_map_plan_decision_arbitrary_prose_is_request_changes_not_abandon() -> None: """THE ANTI-FAIL TEST: arbitrary change prose -> request_changes, NOT abandon. The graph maps any unrecognized verb to abandon -> terminal FAILED. This is the load-bearing guard that real change-request notes never silently FAIL a task: free prose becomes request_changes carrying the FULL original reply as notes, and is explicitly asserted to NOT be abandon (or approve). """ raw = "use pytest fixtures instead of setUp methods" result = map_plan_decision(raw) assert result["decision"] == "request_changes" assert result["decision"] != "abandon" assert result["decision"] != "approve" # The full original reply is preserved as the notes. assert result["notes"] == raw def test_map_plan_decision_preserves_original_casing_in_notes() -> None: """request_changes notes keep the human's exact wording (not lowercased).""" raw = "Please Add Type Hints To The New Helper" result = map_plan_decision(raw) assert result == {"decision": "request_changes", "notes": raw} @pytest.mark.parametrize("raw", ["", " ", "\n\t "]) def test_map_plan_decision_empty_is_request_changes_safe_default(raw: str) -> None: """Empty / whitespace-only input maps to request_changes (never abandon).""" result = map_plan_decision(raw) assert result["decision"] == "request_changes" assert result["decision"] != "abandon" assert result["notes"] == "" def test_map_plan_decision_none_is_request_changes() -> None: """A None answer degrades to request_changes with empty notes (never abandon).""" result = map_plan_decision(None) assert result == {"decision": "request_changes", "notes": ""} def test_map_plan_decision_verb_with_trailing_text_is_request_changes() -> None: """A verb embedded in a sentence is NOT a bare verb -> request_changes. "approve but tweak X" is a change request, not an approval โ€” only an exact bare verb match approves. """ raw = "approve but please tweak the error handling first" result = map_plan_decision(raw) assert result["decision"] == "request_changes" assert result["notes"] == raw # --------------------------------------------------------------------------- # # normalize_decision โ€” shared transport-neutral normalizer (decisions.py) # # --------------------------------------------------------------------------- # @pytest.mark.parametrize("verb", ["abandon", "reject", "cancel", "stop", "kill"]) def test_normalize_decision_free_text_destructive_verb_not_abandon(verb: str) -> None: """LOGIC-05: with allow_abandon=False, destructive verbs -> request_changes. A bare destructive verb (the free-text path) is NOT a confirmed abandon; it carries the verb as notes and requests changes instead of terminally failing. """ from agent_team.decisions import normalize_decision result = normalize_decision(verb, allow_abandon=False) assert result["decision"] == "request_changes" assert result["decision"] != "abandon" assert result["notes"] == verb @pytest.mark.parametrize("verb", ["abandon", "reject", "cancel", "stop", "kill"]) def test_normalize_decision_button_destructive_verb_abandons(verb: str) -> None: """With allow_abandon=True (confirmed button), destructive verbs abandon.""" from agent_team.decisions import normalize_decision result = normalize_decision(verb, allow_abandon=True) assert result == {"decision": "abandon", "notes": ""} @pytest.mark.parametrize( "decision", [ {"decision": "approve", "notes": ""}, {"decision": "request_changes", "notes": "do X"}, {"decision": "abandon", "notes": ""}, ], ) @pytest.mark.parametrize("allow_abandon", [True, False]) def test_normalize_decision_idempotent_on_decision_dict( decision: dict, allow_abandon: bool ) -> None: """An already-formed valid decision dict passes through normalized, un-remapped. Idempotency: re-normalizing a dict that already carries a valid decision returns it unchanged (notes preserved). An explicit abandon dict is honoured even with allow_abandon=False โ€” it is an explicit decision, not free text. """ from agent_team.decisions import normalize_decision result = normalize_decision(decision, allow_abandon=allow_abandon) assert result == decision # Double-application is stable. assert normalize_decision(result, allow_abandon=allow_abandon) == decision def test_normalize_decision_idempotent_normalizes_casing() -> None: """A decision dict with odd casing/whitespace is normalized (not re-mapped).""" from agent_team.decisions import normalize_decision result = normalize_decision( {"decision": " Request_Changes ", "notes": "Keep Casing"}, allow_abandon=True, ) assert result == {"decision": "request_changes", "notes": "Keep Casing"} def test_map_plan_decision_free_text_abandon_verb_requests_changes() -> None: """The wrapper threads allow_abandon: free-text 'cancel' -> request_changes.""" result = map_plan_decision("cancel", allow_abandon=False) assert result["decision"] == "request_changes" assert result["notes"] == "cancel" # --------------------------------------------------------------------------- # # B3 โ€” gate decision blocks (the three buttons) # # --------------------------------------------------------------------------- # def test_build_plan_decision_blocks_has_three_buttons_with_action_ids() -> None: blocks = build_plan_decision_blocks("q-xyz", "Plan summary + findings") # A section (the body) + an actions block with three buttons. assert blocks[0]["type"] == "section" assert "Plan summary" in blocks[0]["text"]["text"] actions = blocks[1] assert actions["type"] == "actions" action_ids = [e["action_id"] for e in actions["elements"]] assert action_ids == [ PLAN_DECISION_APPROVE_ACTION, PLAN_DECISION_REQUEST_CHANGES_ACTION, PLAN_DECISION_ABANDON_ACTION, ] def test_build_plan_decision_blocks_buttons_embed_recoverable_question_id() -> None: """Each button value encodes ``":"`` for recovery.""" blocks = build_plan_decision_blocks("q-recover", "body") values = [e["value"] for e in blocks[1]["elements"]] assert values == [ "approve:q-recover", "request_changes:q-recover", "abandon:q-recover", ] # The question_id is recoverable from every button value. for v in values: assert v.endswith(":q-recover") def test_build_plan_decision_blocks_abandon_has_danger_confirm() -> None: blocks = build_plan_decision_blocks("q1", "body") abandon = blocks[1]["elements"][2] assert abandon["style"] == "danger" assert "confirm" in abandon approve = blocks[1]["elements"][0] assert approve["style"] == "primary" # --------------------------------------------------------------------------- # # B3 โ€” block_actions button parse_answer recovery # # --------------------------------------------------------------------------- # def test_parse_answer_recovers_qid_and_verb_from_button_value() -> None: """An approve button with no callback_id recovers qid + bare verb from value.""" t = SlackTransport(channel="C1", poster=_RecordingPoster()) payload = { "type": "block_actions", "actions": [ {"action_id": PLAN_DECISION_APPROVE_ACTION, "value": "approve:q-btn"} ], } qid, answer, via = t.parse_answer(payload) assert qid == "q-btn" # The answer is the bare verb (suffix stripped) so map_plan_decision matches. assert answer == "approve" assert via == VIA_SLACK def test_parse_answer_recovers_abandon_button_value() -> None: t = SlackTransport(channel="C1", poster=_RecordingPoster()) payload = { "type": "block_actions", "actions": [{"action_id": PLAN_DECISION_ABANDON_ACTION, "value": "abandon:q9"}], } qid, answer, _via = t.parse_answer(payload) assert qid == "q9" assert answer == "abandon" def test_parse_answer_button_metadata_takes_precedence_for_qid() -> None: """When message metadata carries the qid, it is used (callback_id dropped live).""" t = SlackTransport(channel="C1", poster=_RecordingPoster()) payload = { "type": "block_actions", "message": { "metadata": {"event_payload": {"question_id": "q-meta"}}, }, "actions": [ {"action_id": PLAN_DECISION_APPROVE_ACTION, "value": "approve:q-meta"} ], } qid, answer, _via = t.parse_answer(payload) assert qid == "q-meta" assert answer == "approve" # --------------------------------------------------------------------------- # # B3 โ€” request-changes modal + view_submission # # --------------------------------------------------------------------------- # def test_build_request_changes_modal_round_trips_question_id() -> None: modal = build_request_changes_modal("q-modal") assert modal["type"] == "modal" assert modal["private_metadata"] == "request_changes:q-modal" # One required multiline input. block = modal["blocks"][0] assert block["type"] == "input" assert block["element"]["type"] == "plain_text_input" assert block["element"]["multiline"] is True def _view_submission(question_id: str, notes: str) -> dict[str, Any]: """A realistic ``view_submission`` payload for the request-changes modal.""" return { "type": "view_submission", "user": {"id": "U_OWNER"}, "view": { "callback_id": "plan_decision:request_changes_modal", "private_metadata": f"request_changes:{question_id}", "state": { "values": { "plan_decision_notes_block": { "plan_decision_notes_input": { "type": "plain_text_input", "value": notes, } } } }, }, } def test_parse_answer_recovers_qid_and_notes_from_view_submission() -> None: """The modal submit recovers qid from private_metadata + notes from input.""" t = SlackTransport(channel="C1", poster=_RecordingPoster()) payload = _view_submission("q-sub", "switch to dependency injection") qid, answer, via = t.parse_answer(payload) assert qid == "q-sub" assert answer == "switch to dependency injection" assert via == VIA_SLACK def test_view_submission_notes_map_to_request_changes() -> None: """The modal's free-text notes normalize to request_changes (via the mapper).""" t = SlackTransport(channel="C1", poster=_RecordingPoster()) _qid, answer, _via = t.parse_answer( _view_submission("q1", "use a factory function") ) decision = map_plan_decision(answer) assert decision == { "decision": "request_changes", "notes": "use a factory function", } # --------------------------------------------------------------------------- # # B3 โ€” post_question renders decision buttons for a plan_decision question # # --------------------------------------------------------------------------- # def test_post_question_renders_plan_decision_buttons() -> None: poster = _RecordingPoster() t = SlackTransport(channel="C1", poster=poster) qs = QuestionSet( thread_id="t1", question_id="q-gate", turn=2, questions=["Approve, request changes, or abandon this plan?"], context={"kind": PLAN_DECISION_KIND, "presentation": "Plan body here"}, ) t.post_question( thread_id="t1", question_id="q-gate", turn=2, question_set=qs, deadline="2026-06-18T00:00:00Z", ) blocks = poster.calls[0]["blocks"] action_ids = [ e["action_id"] for b in blocks if b.get("type") == "actions" for e in b["elements"] ] assert PLAN_DECISION_APPROVE_ACTION in action_ids assert PLAN_DECISION_REQUEST_CHANGES_ACTION in action_ids assert PLAN_DECISION_ABANDON_ACTION in action_ids # The presentation body is the message text fallback. assert poster.calls[0]["text"] == "Plan body here" # The question_id is double-anchored in message metadata. assert poster.calls[0]["metadata"]["event_payload"]["question_id"] == "q-gate"