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_adapter.py
Adam Moussa 15a416d31a Add Plane-2 leaf scaffold (pipeline graph, nodes, HITL, transports, CI)
Consolidates the 18 leaf modules from the r720-plane2-scaffold workflow onto
the foundation commit. Full suite: 535 passed, 1 skipped; ruff + format clean.

Built (pre-deployment scaffold only — nothing provisioned/enabled):
- LangGraph pipeline graph.py (INTAKE->CLARIFY->PLAN, interrupt()/resume, checkpointer-injectable)
- nodes: clarifier (98% gate), planner, review_loop (GPT-4.1), builders->candidate diff, verifier
- §3.3.1 HITL: ledger ops, resume_worker, deadline_timer, recovery sweep, responder
- transports: slack / github / claude_code adapters
- ci_gate (pure-code pass/fail), operator_cli, run-team.py entry, P1 sim harness
- ci/agent-team-apply-verify.yml (split untrusted/privileged jobs) — authored, disabled

KNOWN OPEN FINDINGS (verifier/cross-review, not yet fixed — see follow-up):
- builders denylist: 4 execution-proven bypasses (delete, mode-change, copy-to, out-of-scope delete)
- §3.3.1 CAS: BEGIN IMMEDIATE outside try/except; shared-connection txn nesting unsafe under concurrency
- operator_cli: missing re-deliver/force-resume; audit-after-mutate ordering gap
- ci yaml: GPT-4.1 cross-review PASS w/ 4 FIX items (symlink path escape, etc.)
- P1 sim harness models the ledger layer, not real LangGraph interrupt/resume; P1 exit criteria not yet truly proven

Deploy-gated (NOT done): IAM/step-ca/Roles Anywhere/confluence-bot provisioning,
/sh-security-review sign-off, live Slack/CI, rsync, live dry-runs, Adam approval.
2026-06-17 15:16:12 -04:00

358 lines
12 KiB
Python

"""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