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

747 lines
27 KiB
Python
Raw Normal View History

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 14:17:44 -04:00
"""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,
feat(agent-team): Slack decision surface for the plan-review gate (Phase B3) Turn a human's Slack interaction at the plan gate into a structured decision the graph can route, with the kind-aware mapping that closes a silent-FAIL hazard. - KIND-AWARE NORMALIZATION (load-bearing): map_plan_decision() in slack_adapter maps a reply to {"decision","notes"} — approve ∈ {approve,approved,yes,ok,lgtm, ship}; abandon ∈ {abandon,reject,cancel,stop,kill}; EVERYTHING ELSE → request_changes with the full reply as notes (never accidental abandon). Wired in the listener's _resolve_payload for plan_decision rows ONLY (clarify passes through). Without this, arbitrary change-notes hit the graph's unrecognized-verb→FAILED path and silently fail the task. Anti-FAIL tests assert prose → request_changes (!= abandon) at both the mapper and the end-to-end listener seam; a regression test guards clarify pass-through. New find_open_question_kind_by_channel_ref (anti-replay, status='open') powers the thread-reply fallback's kind lookup. - BUTTONS + MODAL: build_plan_decision_blocks() renders Approve (primary) / Request changes / Abandon (danger+confirm); question_id double-anchored in message metadata AND each button value ("<verb>:<question_id>"). Approve/abandon submit via the existing @app.action(.*); request_changes has a dedicated handler that AUTHORIZES before views_open (proven by test) and opens a notes modal (private_metadata carries the id) → view_submission → request_changes + notes. Free-text reply stays the always-available equal path. AUTHZ-01 ordering preserved. - No manifest change (views.open needs no extra scope). 1454 passed (1412 + 42).
2026-06-23 20:34:58 -04:00
PLAN_DECISION_ABANDON_ACTION,
PLAN_DECISION_APPROVE_ACTION,
PLAN_DECISION_KIND,
PLAN_DECISION_REQUEST_CHANGES_ACTION,
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 14:17:44 -04:00
VIA_SLACK,
SlackPostError,
SlackTransport,
build_callback_id,
feat(agent-team): Slack decision surface for the plan-review gate (Phase B3) Turn a human's Slack interaction at the plan gate into a structured decision the graph can route, with the kind-aware mapping that closes a silent-FAIL hazard. - KIND-AWARE NORMALIZATION (load-bearing): map_plan_decision() in slack_adapter maps a reply to {"decision","notes"} — approve ∈ {approve,approved,yes,ok,lgtm, ship}; abandon ∈ {abandon,reject,cancel,stop,kill}; EVERYTHING ELSE → request_changes with the full reply as notes (never accidental abandon). Wired in the listener's _resolve_payload for plan_decision rows ONLY (clarify passes through). Without this, arbitrary change-notes hit the graph's unrecognized-verb→FAILED path and silently fail the task. Anti-FAIL tests assert prose → request_changes (!= abandon) at both the mapper and the end-to-end listener seam; a regression test guards clarify pass-through. New find_open_question_kind_by_channel_ref (anti-replay, status='open') powers the thread-reply fallback's kind lookup. - BUTTONS + MODAL: build_plan_decision_blocks() renders Approve (primary) / Request changes / Abandon (danger+confirm); question_id double-anchored in message metadata AND each button value ("<verb>:<question_id>"). Approve/abandon submit via the existing @app.action(.*); request_changes has a dedicated handler that AUTHORIZES before views_open (proven by test) and opens a notes modal (private_metadata carries the id) → view_submission → request_changes + notes. Free-text reply stays the always-available equal path. AUTHZ-01 ordering preserved. - No manifest change (views.open needs no extra scope). 1454 passed (1412 + 42).
2026-06-23 20:34:58 -04:00
build_plan_decision_blocks,
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 14:17:44 -04:00
build_question_blocks,
feat(agent-team): Slack decision surface for the plan-review gate (Phase B3) Turn a human's Slack interaction at the plan gate into a structured decision the graph can route, with the kind-aware mapping that closes a silent-FAIL hazard. - KIND-AWARE NORMALIZATION (load-bearing): map_plan_decision() in slack_adapter maps a reply to {"decision","notes"} — approve ∈ {approve,approved,yes,ok,lgtm, ship}; abandon ∈ {abandon,reject,cancel,stop,kill}; EVERYTHING ELSE → request_changes with the full reply as notes (never accidental abandon). Wired in the listener's _resolve_payload for plan_decision rows ONLY (clarify passes through). Without this, arbitrary change-notes hit the graph's unrecognized-verb→FAILED path and silently fail the task. Anti-FAIL tests assert prose → request_changes (!= abandon) at both the mapper and the end-to-end listener seam; a regression test guards clarify pass-through. New find_open_question_kind_by_channel_ref (anti-replay, status='open') powers the thread-reply fallback's kind lookup. - BUTTONS + MODAL: build_plan_decision_blocks() renders Approve (primary) / Request changes / Abandon (danger+confirm); question_id double-anchored in message metadata AND each button value ("<verb>:<question_id>"). Approve/abandon submit via the existing @app.action(.*); request_changes has a dedicated handler that AUTHORIZES before views_open (proven by test) and opens a notes modal (private_metadata carries the id) → view_submission → request_changes + notes. Free-text reply stays the always-available equal path. AUTHZ-01 ordering preserved. - No manifest change (views.open needs no extra scope). 1454 passed (1412 + 42).
2026-06-23 20:34:58 -04:00
build_request_changes_modal,
map_plan_decision,
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 14:17:44 -04:00
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"
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
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
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 14:17:44 -04:00
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
feat(agent-team): Slack decision surface for the plan-review gate (Phase B3) Turn a human's Slack interaction at the plan gate into a structured decision the graph can route, with the kind-aware mapping that closes a silent-FAIL hazard. - KIND-AWARE NORMALIZATION (load-bearing): map_plan_decision() in slack_adapter maps a reply to {"decision","notes"} — approve ∈ {approve,approved,yes,ok,lgtm, ship}; abandon ∈ {abandon,reject,cancel,stop,kill}; EVERYTHING ELSE → request_changes with the full reply as notes (never accidental abandon). Wired in the listener's _resolve_payload for plan_decision rows ONLY (clarify passes through). Without this, arbitrary change-notes hit the graph's unrecognized-verb→FAILED path and silently fail the task. Anti-FAIL tests assert prose → request_changes (!= abandon) at both the mapper and the end-to-end listener seam; a regression test guards clarify pass-through. New find_open_question_kind_by_channel_ref (anti-replay, status='open') powers the thread-reply fallback's kind lookup. - BUTTONS + MODAL: build_plan_decision_blocks() renders Approve (primary) / Request changes / Abandon (danger+confirm); question_id double-anchored in message metadata AND each button value ("<verb>:<question_id>"). Approve/abandon submit via the existing @app.action(.*); request_changes has a dedicated handler that AUTHORIZES before views_open (proven by test) and opens a notes modal (private_metadata carries the id) → view_submission → request_changes + notes. Free-text reply stays the always-available equal path. AUTHZ-01 ordering preserved. - No manifest change (views.open needs no extra scope). 1454 passed (1412 + 42).
2026-06-23 20:34:58 -04:00
# --------------------------------------------------------------------------- #
# 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
2026-06-24 11:48:04 -04:00
# --------------------------------------------------------------------------- #
# 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"
feat(agent-team): Slack decision surface for the plan-review gate (Phase B3) Turn a human's Slack interaction at the plan gate into a structured decision the graph can route, with the kind-aware mapping that closes a silent-FAIL hazard. - KIND-AWARE NORMALIZATION (load-bearing): map_plan_decision() in slack_adapter maps a reply to {"decision","notes"} — approve ∈ {approve,approved,yes,ok,lgtm, ship}; abandon ∈ {abandon,reject,cancel,stop,kill}; EVERYTHING ELSE → request_changes with the full reply as notes (never accidental abandon). Wired in the listener's _resolve_payload for plan_decision rows ONLY (clarify passes through). Without this, arbitrary change-notes hit the graph's unrecognized-verb→FAILED path and silently fail the task. Anti-FAIL tests assert prose → request_changes (!= abandon) at both the mapper and the end-to-end listener seam; a regression test guards clarify pass-through. New find_open_question_kind_by_channel_ref (anti-replay, status='open') powers the thread-reply fallback's kind lookup. - BUTTONS + MODAL: build_plan_decision_blocks() renders Approve (primary) / Request changes / Abandon (danger+confirm); question_id double-anchored in message metadata AND each button value ("<verb>:<question_id>"). Approve/abandon submit via the existing @app.action(.*); request_changes has a dedicated handler that AUTHORIZES before views_open (proven by test) and opens a notes modal (private_metadata carries the id) → view_submission → request_changes + notes. Free-text reply stays the always-available equal path. AUTHZ-01 ordering preserved. - No manifest change (views.open needs no extra scope). 1454 passed (1412 + 42).
2026-06-23 20:34:58 -04:00
# --------------------------------------------------------------------------- #
# 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 ``"<verb>:<question_id>"`` 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"