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/agent_team/transport/slack_live.py
Adam Moussa 8d31ef183d feat(agent-team): one Slack thread per task — root "Task received" message + threaded questions/milestones
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.
2026-06-23 15:11:19 -04:00

178 lines
8.2 KiB
Python

"""Live ``slack_sdk``-backed Slack poster (design §3.3.1, §7.1 P1 — Slack first).
The :mod:`agent_team.transport.slack_adapter` module ships the §3.3.1 transport
contract with a dependency-injected ``poster`` seam: the adapter renders the
question-set into a message dict and hands it to a
``SlackPoster = Callable[[dict[str, Any]], Mapping[str, Any]]`` whose job is to
perform the real ``chat.postMessage`` and return a response carrying the message
``ts``. The foundation's default poster refuses the network so nothing ships
provisioned; this module supplies the **production** poster, backed by
``slack_sdk.WebClient``, that the P1 (Slack first) live wiring injects.
Deferred import (mirrors :func:`agent_team.graph.build_sqlite_checkpointer`):
``slack_sdk`` is an optional dependency that may be absent in pre-deploy /
test environments, so this module imports cleanly without it. The import is
deferred to the moment a live client is actually constructed, and a missing
package raises a clear :class:`RuntimeError` so a misconfigured deploy fails
loudly rather than silently.
Message-dict to ``chat.postMessage`` mapping
--------------------------------------------
The adapter's message dict (see ``SlackTransport.post_question``) carries
``channel``, ``callback_id``, ``text``, ``blocks`` and ``metadata``. Slack's
``chat.postMessage`` Web API method does **not** accept a top-level
``callback_id`` keyword argument (``callback_id`` is a legacy attachment /
interactive-component field, not a message-post parameter), so passing it
through verbatim would raise a ``TypeError`` / Slack ``invalid_arguments``.
The durable inbound key is therefore carried by ``metadata`` instead: the
adapter embeds ``question_id`` under ``metadata.event_payload.question_id``, and
``slack_adapter._extract_question_id`` reads exactly that path off an inbound
message. ``chat.postMessage`` *does* accept ``metadata``, so forwarding it
preserves the inbound mapping. The poster consequently **drops** ``callback_id``
from the postMessage kwargs and forwards only the parameters the Web API
accepts (``channel``, ``text``, ``blocks``, ``metadata``), letting ``metadata``
do the question-id round-trip the adapter relies on.
"""
from __future__ import annotations
import os
from collections.abc import Callable, Mapping
from typing import Any
from agent_team.transport.slack_adapter import SlackPoster, SlackTransport
__all__ = [
"build_live_slack_transport",
"build_slack_poster",
"build_slack_reactor",
]
# Top-level ``chat.postMessage`` keyword arguments the live poster forwards.
# ``callback_id`` is deliberately excluded: it is not a postMessage parameter,
# and the durable inbound key lives in ``metadata.event_payload`` instead.
# ``thread_ts`` IS a postMessage parameter (one-thread-per-task threading) and is
# forwarded when present so a question/notification posts as a threaded reply.
_POST_MESSAGE_KEYS = ("channel", "text", "blocks", "metadata", "thread_ts")
def build_slack_poster(token: str | None = None, *, client: Any = None) -> SlackPoster:
"""Build a live ``slack_sdk``-backed :data:`SlackPoster` (§3.3.1, P1).
The returned callable accepts the adapter's rendered message dict, performs
a ``chat.postMessage``, and returns the response as a mapping carrying the
message ``ts`` so ``SlackTransport._extract_ts`` can record the
``channel_ref``.
``client`` (optional) injects a pre-built Slack client for testability; any
object exposing ``chat_postMessage(**kwargs)`` works. When omitted, a
``slack_sdk.WebClient`` is constructed lazily from ``token`` (falling back to
the ``SLACK_BOT_TOKEN`` environment variable). The ``slack_sdk`` import is
deferred so this module imports cleanly without the optional package; a
missing package or a missing token raises a clear :class:`RuntimeError`.
The poster maps the adapter's message dict to the Web API's accepted
parameters: it forwards ``channel``, ``text``, ``blocks`` and ``metadata``
and **drops** ``callback_id`` (not a ``chat.postMessage`` parameter — the
``question_id`` round-trips via ``metadata.event_payload`` instead). See the
module docstring for the full rationale.
"""
if client is None:
client = _build_web_client(token)
def _poster(message: dict[str, Any]) -> Mapping[str, Any]:
kwargs = {key: message[key] for key in _POST_MESSAGE_KEYS if key in message}
response = client.chat_postMessage(**kwargs)
return _as_mapping(response)
return _poster
def build_slack_reactor(
token: str | None = None, *, client: Any = None
) -> "Callable[[str, str], None]":
"""Build a live ``slack_sdk``-backed 👍-reaction adder (one-thread-per-task UX).
The returned ``(channel, ts) -> None`` callable performs a Slack
``reactions.add`` (emoji ``thumbsup``) on the message at ``(channel, ts)`` so
a human sees the machine received their inbound answer. It is wired into the
inbound :class:`~agent_team.transport.slack_listener.SlackListener` (which
only calls it AFTER the AUTHZ-01 owner check passes) and is invoked
best-effort — the listener swallows any failure.
Requires the ``reactions:write`` bot scope. Until that scope is granted (the
manifest re-applied + the app reinstalled) ``reactions.add`` fails with a
``missing_scope`` error; this reactor lets that propagate to the listener,
which swallows it, so the reaction silently no-ops rather than breaking
answer handling.
``client`` (optional) injects a pre-built client for testability; any object
exposing ``reactions_add(**kwargs)`` works. When omitted, a
``slack_sdk.WebClient`` is constructed lazily from ``token`` (falling back to
``SLACK_BOT_TOKEN``); the deferred-import / missing-token semantics match
:func:`build_slack_poster`.
"""
if client is None:
client = _build_web_client(token)
def _reactor(channel: str, ts: str) -> None:
client.reactions_add(channel=channel, timestamp=ts, name="thumbsup")
return _reactor
def build_live_slack_transport(
channel: str, token: str | None = None, *, client: Any = None
) -> SlackTransport:
"""Build a :class:`SlackTransport` wired to a live ``slack_sdk`` poster.
Convenience constructor for the P1 live coordinator: equivalent to
``SlackTransport(channel, poster=build_slack_poster(token, client=client))``.
See :func:`build_slack_poster` for the token / client / deferred-import
semantics.
"""
return SlackTransport(channel, poster=build_slack_poster(token, client=client))
def _build_web_client(token: str | None) -> Any:
"""Lazily construct a ``slack_sdk.WebClient`` (deferred optional import).
Raises a clear :class:`RuntimeError` if ``slack_sdk`` is not installed or no
token is resolvable (neither ``token`` nor ``SLACK_BOT_TOKEN``), so a
misconfigured deploy fails loudly rather than silently.
"""
try:
from slack_sdk import WebClient
except ImportError as exc: # pragma: no cover - depends on optional dep
raise RuntimeError(
"slack_sdk is unavailable; install the 'slack_sdk' package to build "
"a live Slack poster (P1), or inject a 'client' for testing."
) from exc
resolved = token or os.environ.get("SLACK_BOT_TOKEN")
if not resolved:
raise RuntimeError(
"No Slack bot token available; pass 'token' or set the "
"SLACK_BOT_TOKEN environment variable to build a live Slack poster."
)
return WebClient(token=resolved)
def _as_mapping(response: Any) -> Mapping[str, Any]:
"""Coerce a ``chat_postMessage`` response to a plain mapping.
``slack_sdk`` returns a ``SlackResponse`` exposing the payload via ``.data``;
if a test injects a client returning a bare mapping, accept it as-is. The
result must carry ``ts`` so ``SlackTransport._extract_ts`` recovers the
``channel_ref``.
"""
if isinstance(response, Mapping):
return response
data = getattr(response, "data", None)
if isinstance(data, Mapping):
return data
raise TypeError(
"Slack chat_postMessage returned an unsupported response; expected a "
f"mapping or an object with a mapping '.data', got {type(response)!r}"
)