open-swe/tests/test_slack_thread_reply_tool.py
seahaven-openswe[bot] 9f7a1cc481
Some checks are pending
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
feat: post scheduled-run report to a configured Slack channel (#83)
* Post scheduled-run reports to a Slack channel

Scheduled runs previously had no source channel and finished silently
in the dashboard. Allow an automation to post its final report to a
configured Slack channel as the bot, reusing existing Slack plumbing
instead of the deferred run-completion webhook.

Refs: #82

* Reconcile Slack report feature with dev merge

Dev refactored slack_thread_reply to async and already added
post_slack_top_level_message_with_ts; drop the duplicate definition
and await the tool in the feature's tests.

Refs: #82

* Harden scheduled Slack report channel posting

The no-thread_ts top-level path fired for every slack_thread_reply call
during a scheduled run, spraying disconnected messages and dead
interactive buttons into the report channel. Cap top-level posts at one
per run and drop options/plan_approval blocks in that mode, so the
mechanism (not just the prompt) enforces a single clean report. Also
tighten the channel-ID regex to require a leading letter and document
why top-level posts store no run mapping.

Refs: #82

---------

Co-authored-by: amoussa1229 <166072409+amoussa1229@users.noreply.github.com>
2026-06-30 17:34:52 -04:00

325 lines
11 KiB
Python

from __future__ import annotations
import importlib
from typing import Any
import pytest
slack_reply_tool = importlib.import_module("agent.tools.slack_thread_reply")
def _config() -> dict[str, Any]:
return {
"configurable": {
"slack_thread": {
"channel_id": "C1",
"thread_ts": "1.0",
}
}
}
async def test_slack_thread_reply_returns_structured_error_for_msg_too_long(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_post_and_store_mapping(
channel_id: str,
thread_ts: str,
message: str,
*,
blocks: list[dict[str, Any]] | None = None,
) -> tuple[str | None, str | None]:
return None, "msg_too_long"
monkeypatch.setattr(slack_reply_tool, "get_config", _config)
monkeypatch.setattr(slack_reply_tool, "_post_and_store_mapping", fake_post_and_store_mapping)
result = await slack_reply_tool.slack_thread_reply("hello")
assert result == {
"success": False,
"error": "msg_too_long",
"slack_error": "msg_too_long",
"message_chars": 5,
"hint": "Slack rejected the message as too long; retry with a shorter message.",
}
@pytest.mark.parametrize("slack_error", ["channel_not_found", "not_in_channel"])
async def test_slack_thread_reply_hints_not_to_retry_channel_errors(
slack_error: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_post_and_store_mapping(
channel_id: str,
thread_ts: str,
message: str,
*,
blocks: list[dict[str, Any]] | None = None,
) -> tuple[str | None, str | None]:
return None, slack_error
monkeypatch.setattr(slack_reply_tool, "get_config", _config)
monkeypatch.setattr(slack_reply_tool, "_post_and_store_mapping", fake_post_and_store_mapping)
result = await slack_reply_tool.slack_thread_reply("hello")
assert result["success"] is False
assert result["error"] == slack_error
assert result["slack_error"] == slack_error
assert result["message_chars"] == 5
assert "do not retry" in result["hint"]
assert "trace output" in result["hint"]
async def test_slack_thread_reply_rate_limited_hint_includes_retry_after(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_post_and_store_mapping(
channel_id: str,
thread_ts: str,
message: str,
*,
blocks: list[dict[str, Any]] | None = None,
) -> tuple[str | None, str | None]:
return None, "rate_limited: 30"
monkeypatch.setattr(slack_reply_tool, "get_config", _config)
monkeypatch.setattr(slack_reply_tool, "_post_and_store_mapping", fake_post_and_store_mapping)
result = await slack_reply_tool.slack_thread_reply("hello")
assert result["success"] is False
assert result["error"] == "rate_limited: 30"
assert result["slack_error"] == "rate_limited: 30"
assert "30s" in result["hint"]
assert "wait" in result["hint"]
async def test_slack_thread_reply_rate_limited_hint_without_retry_after(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_post_and_store_mapping(
channel_id: str,
thread_ts: str,
message: str,
*,
blocks: list[dict[str, Any]] | None = None,
) -> tuple[str | None, str | None]:
return None, "rate_limited"
monkeypatch.setattr(slack_reply_tool, "get_config", _config)
monkeypatch.setattr(slack_reply_tool, "_post_and_store_mapping", fake_post_and_store_mapping)
result = await slack_reply_tool.slack_thread_reply("hello")
assert result["success"] is False
assert result["slack_error"] == "rate_limited"
assert "wait" in result["hint"]
async def test_slack_thread_reply_uses_post_failed_without_slack_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_post_and_store_mapping(
channel_id: str,
thread_ts: str,
message: str,
*,
blocks: list[dict[str, Any]] | None = None,
) -> tuple[str | None, str | None]:
return None, None
monkeypatch.setattr(slack_reply_tool, "get_config", _config)
monkeypatch.setattr(slack_reply_tool, "_post_and_store_mapping", fake_post_and_store_mapping)
result = await slack_reply_tool.slack_thread_reply("hello")
assert result["success"] is False
assert result["error"] == "post failed"
assert result["slack_error"] is None
assert result["message_chars"] == 5
async def test_slack_thread_reply_builds_option_blocks(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, Any] = {}
async def fake_post_and_store_mapping(
channel_id: str,
thread_ts: str,
message: str,
*,
blocks: list[dict[str, Any]] | None = None,
) -> tuple[str | None, str | None]:
captured.update(
{"channel_id": channel_id, "thread_ts": thread_ts, "message": message, "blocks": blocks}
)
return "2.0", None
monkeypatch.setattr(slack_reply_tool, "get_config", _config)
monkeypatch.setattr(slack_reply_tool, "_post_and_store_mapping", fake_post_and_store_mapping)
result = await slack_reply_tool.slack_thread_reply("Pick one", options=["A", "B"])
assert result == {"success": True}
assert captured["channel_id"] == "C1"
assert captured["thread_ts"] == "1.0"
assert captured["message"] == "Pick one"
actions = captured["blocks"][1]
assert actions["type"] == "actions"
assert [button["text"]["text"] for button in actions["elements"]] == ["A", "B"]
assert actions["elements"][0]["action_id"] == "open_swe_option_select"
def _channel_only_config() -> dict[str, Any]:
return {"configurable": {"slack_thread": {"channel_id": "C9"}}}
def _channel_run_config() -> dict[str, Any]:
return {
"configurable": {
"slack_thread": {"channel_id": "C9"},
"thread_id": "run-1",
}
}
@pytest.fixture(autouse=True)
def _reset_top_level_posts() -> Any:
slack_reply_tool._top_level_posts.clear()
yield
slack_reply_tool._top_level_posts.clear()
async def test_slack_thread_reply_requires_channel_id(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(slack_reply_tool, "get_config", lambda: {"configurable": {}})
result = await slack_reply_tool.slack_thread_reply("hello")
assert result["success"] is False
assert result["error"] == "Missing slack_thread.channel_id in config"
async def test_slack_thread_reply_posts_top_level_when_no_thread_ts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
async def fake_top_level(
channel_id: str,
text: str,
*,
blocks: list[dict[str, Any]] | None = None,
) -> tuple[str | None, str | None]:
captured.update({"channel_id": channel_id, "text": text, "blocks": blocks})
return "3.0", None
async def fail_thread_reply(*args: Any, **kwargs: Any) -> tuple[str | None, str | None]:
raise AssertionError("should not post a thread reply without thread_ts")
monkeypatch.setattr(slack_reply_tool, "get_config", _channel_only_config)
monkeypatch.setattr(slack_reply_tool, "post_slack_top_level_message_with_ts", fake_top_level)
monkeypatch.setattr(slack_reply_tool, "post_slack_thread_reply_with_ts", fail_thread_reply)
result = await slack_reply_tool.slack_thread_reply("Scheduled report")
assert result == {"success": True}
assert captured["channel_id"] == "C9"
assert captured["text"] == "Scheduled report"
async def test_slack_thread_reply_top_level_surfaces_not_in_channel(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_top_level(
channel_id: str,
text: str,
*,
blocks: list[dict[str, Any]] | None = None,
) -> tuple[str | None, str | None]:
return None, "not_in_channel"
monkeypatch.setattr(slack_reply_tool, "get_config", _channel_only_config)
monkeypatch.setattr(slack_reply_tool, "post_slack_top_level_message_with_ts", fake_top_level)
result = await slack_reply_tool.slack_thread_reply("Scheduled report")
assert result["success"] is False
assert result["slack_error"] == "not_in_channel"
assert "do not retry" in result["hint"]
async def test_slack_thread_reply_top_level_drops_interactive_blocks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
async def fake_top_level(
channel_id: str,
text: str,
*,
blocks: list[dict[str, Any]] | None = None,
) -> tuple[str | None, str | None]:
captured["blocks"] = blocks
return "3.0", None
monkeypatch.setattr(slack_reply_tool, "get_config", _channel_only_config)
monkeypatch.setattr(slack_reply_tool, "post_slack_top_level_message_with_ts", fake_top_level)
options_result = await slack_reply_tool.slack_thread_reply("Pick", options=["A", "B"])
assert options_result == {"success": True}
assert captured["blocks"] is None
approval_result = await slack_reply_tool.slack_thread_reply("Plan?", plan_approval=True)
assert approval_result == {"success": True}
assert captured["blocks"] is None
async def test_slack_thread_reply_allows_only_one_top_level_post_per_run(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls = 0
async def fake_top_level(
channel_id: str,
text: str,
*,
blocks: list[dict[str, Any]] | None = None,
) -> tuple[str | None, str | None]:
nonlocal calls
calls += 1
return "3.0", None
monkeypatch.setattr(slack_reply_tool, "get_config", _channel_run_config)
monkeypatch.setattr(slack_reply_tool, "post_slack_top_level_message_with_ts", fake_top_level)
first = await slack_reply_tool.slack_thread_reply("First")
second = await slack_reply_tool.slack_thread_reply("Second")
assert first == {"success": True}
assert second["success"] is False
assert "Only one top-level message per run" in second["hint"]
assert calls == 1
async def test_slack_thread_reply_failed_top_level_post_does_not_consume_slot(
monkeypatch: pytest.MonkeyPatch,
) -> None:
results = iter([(None, "rate_limited"), ("3.0", None)])
async def fake_top_level(
channel_id: str,
text: str,
*,
blocks: list[dict[str, Any]] | None = None,
) -> tuple[str | None, str | None]:
return next(results)
monkeypatch.setattr(slack_reply_tool, "get_config", _channel_run_config)
monkeypatch.setattr(slack_reply_tool, "post_slack_top_level_message_with_ts", fake_top_level)
first = await slack_reply_tool.slack_thread_reply("First")
second = await slack_reply_tool.slack_thread_reply("Retry")
assert first["success"] is False
assert second == {"success": True}