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}