diff --git a/apps/agent/agent/utils/auth.py b/apps/agent/agent/utils/auth.py index 0420d4af..2abbd491 100644 --- a/apps/agent/agent/utils/auth.py +++ b/apps/agent/agent/utils/auth.py @@ -14,7 +14,7 @@ from langgraph_sdk import get_client from ..encryption import encrypt_token from .linear import comment_on_linear_issue -from .slack import post_slack_thread_reply +from .slack import post_slack_ephemeral_message, post_slack_thread_reply logger = logging.getLogger(__name__) @@ -206,7 +206,33 @@ async def leave_failure_comment( slack_thread = configurable.get("slack_thread", {}) channel_id = slack_thread.get("channel_id") if isinstance(slack_thread, dict) else None thread_ts = slack_thread.get("thread_ts") if isinstance(slack_thread, dict) else None + triggering_user_id = ( + slack_thread.get("triggering_user_id") if isinstance(slack_thread, dict) else None + ) if channel_id and thread_ts: + if isinstance(triggering_user_id, str) and triggering_user_id: + logger.info( + "Posting auth failure ephemeral reply to Slack user %s in channel %s thread %s", + triggering_user_id, + channel_id, + thread_ts, + ) + sent = await post_slack_ephemeral_message( + channel_id=channel_id, + user_id=triggering_user_id, + text=message, + thread_ts=thread_ts, + ) + if sent: + return + logger.warning( + "Failed to post ephemeral auth failure reply for Slack user %s; falling back to thread reply", + triggering_user_id, + ) + else: + logger.warning( + "Missing Slack triggering_user_id for auth failure reply; falling back to thread reply", + ) logger.info( "Posting auth failure reply to Slack channel %s thread %s", channel_id, diff --git a/apps/agent/agent/utils/slack.py b/apps/agent/agent/utils/slack.py index b5cda60b..a7af115c 100644 --- a/apps/agent/agent/utils/slack.py +++ b/apps/agent/agent/utils/slack.py @@ -202,6 +202,39 @@ async def post_slack_thread_reply(channel_id: str, thread_ts: str, text: str) -> return False +async def post_slack_ephemeral_message( + channel_id: str, user_id: str, text: str, thread_ts: str | None = None +) -> bool: + """Post an ephemeral message visible only to one user.""" + if not SLACK_BOT_TOKEN: + return False + + payload: dict[str, str] = { + "channel": channel_id, + "user": user_id, + "text": text, + } + if thread_ts: + payload["thread_ts"] = thread_ts + + async with httpx.AsyncClient() as http_client: + try: + response = await http_client.post( + f"{SLACK_API_BASE_URL}/chat.postEphemeral", + headers=_slack_headers(), + json=payload, + ) + response.raise_for_status() + data = response.json() + if not data.get("ok"): + logger.warning("Slack chat.postEphemeral failed: %s", data.get("error")) + return False + return True + except httpx.HTTPError: + logger.exception("Slack chat.postEphemeral request failed") + return False + + async def add_slack_reaction(channel_id: str, message_ts: str, emoji: str = "eyes") -> bool: """Add a reaction to a Slack message.""" if not SLACK_BOT_TOKEN: diff --git a/apps/agent/tests/test_auth_sources.py b/apps/agent/tests/test_auth_sources.py index 90bf6270..951f260e 100644 --- a/apps/agent/tests/test_auth_sources.py +++ b/apps/agent/tests/test_auth_sources.py @@ -12,19 +12,76 @@ def test_leave_failure_comment_posts_to_slack_thread( ) -> None: called: dict[str, str] = {} - async def fake_post_slack_thread_reply(channel_id: str, thread_ts: str, message: str) -> bool: + async def fake_post_slack_ephemeral_message( + channel_id: str, user_id: str, text: str, thread_ts: str | None = None + ) -> bool: called["channel_id"] = channel_id + called["user_id"] = user_id called["thread_ts"] = thread_ts - called["message"] = message + called["message"] = text return True + async def fake_post_slack_thread_reply(channel_id: str, thread_ts: str, message: str) -> bool: + raise AssertionError("post_slack_thread_reply should not be called when ephemeral succeeds") + + monkeypatch.setattr(auth, "post_slack_ephemeral_message", fake_post_slack_ephemeral_message) monkeypatch.setattr(auth, "post_slack_thread_reply", fake_post_slack_thread_reply) monkeypatch.setattr( auth, "get_config", - lambda: {"configurable": {"slack_thread": {"channel_id": "C123", "thread_ts": "1.2"}}}, + lambda: { + "configurable": { + "slack_thread": { + "channel_id": "C123", + "thread_ts": "1.2", + "triggering_user_id": "U123", + } + } + }, ) asyncio.run(auth.leave_failure_comment("slack", "auth failed")) - assert called == {"channel_id": "C123", "thread_ts": "1.2", "message": "auth failed"} + assert called == { + "channel_id": "C123", + "user_id": "U123", + "thread_ts": "1.2", + "message": "auth failed", + } + + +def test_leave_failure_comment_falls_back_to_slack_thread_when_ephemeral_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + thread_called: dict[str, str] = {} + + async def fake_post_slack_ephemeral_message( + channel_id: str, user_id: str, text: str, thread_ts: str | None = None + ) -> bool: + return False + + async def fake_post_slack_thread_reply(channel_id: str, thread_ts: str, message: str) -> bool: + thread_called["channel_id"] = channel_id + thread_called["thread_ts"] = thread_ts + thread_called["message"] = message + return True + + monkeypatch.setattr(auth, "post_slack_ephemeral_message", fake_post_slack_ephemeral_message) + monkeypatch.setattr(auth, "post_slack_thread_reply", fake_post_slack_thread_reply) + monkeypatch.setattr( + auth, + "get_config", + lambda: { + "configurable": { + "slack_thread": { + "channel_id": "C123", + "thread_ts": "1.2", + "triggering_user_id": "U123", + } + } + }, + ) + + asyncio.run(auth.leave_failure_comment("slack", "auth failed")) + + assert thread_called == {"channel_id": "C123", "thread_ts": "1.2", "message": "auth failed"}