WS Slack-UX Feature 1. A /new-task task now maps to ONE Slack thread instead of
several top-level messages.
- /new-task posts an immediate root "📥 Task received: …" ack and captures its
ts (root_ts); this is the instant acknowledgement.
- root_ts is plumbed into start: new PipelineState/TaskRecord channel
slack_thread_ts, seeded by graph.start_task and threaded through
Coordinator.start_task. The NewTaskCallback is now (task_text, via, root_ts).
- All clarifier questions for the task post as THREADED REPLIES under root_ts
(chat.postMessage thread_ts=root_ts), and each question's ledger channel_ref
is set to root_ts (NOT the reply's own ts). Because answer-mapping resolves a
reply via find_open_question_by_channel_ref(thread_ts), a reply in the root
thread (thread_ts==root_ts) maps to the task's currently-open question with NO
change to the mapping logic or the first-answer-wins CAS. The open-only
partial-unique index still holds (one open question per task at a time).
- Lifecycle milestones (parked / plan-ready / needs-input) and follow-up
questions thread under root_ts too; the notify sink gained an optional
thread_ts kwarg (degrades to top-level on a sink that doesn't accept it).
notify failures still never break tick.
- SlackTransport.post_question + the live poster accept/forward thread_ts.
- No root_ts (non-/new-task origin) ⇒ top-level posts exactly as before.
AUTHZ-01 (owner-allowlist-first, fail-closed) and the atomic open→answered
compare-and-set are unchanged.
Adds plumbing for the inbound-ack reactor seam used by Feature 2 (dormant until
a reactor is injected). Tests cover thread_ts forwarding, channel_ref=root_ts,
graph seeding, and coordinator threading.
398 lines
14 KiB
Python
398 lines
14 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_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
|