diff --git a/src/sentry/seer/entrypoints/slack/metrics.py b/src/sentry/seer/entrypoints/slack/metrics.py index 313ea56743d5..cf1e41588af8 100644 --- a/src/sentry/seer/entrypoints/slack/metrics.py +++ b/src/sentry/seer/entrypoints/slack/metrics.py @@ -6,6 +6,7 @@ class ProcessMentionHaltReason(StrEnum): IDENTITY_NOT_LINKED = "identity_not_linked" USER_NOT_ORG_MEMBER = "user_not_org_member" + OUT_OF_SEER_CREDITS = "out_of_seer_credits" class ProcessMentionFailureReason(StrEnum): diff --git a/src/sentry/seer/entrypoints/slack/tasks.py b/src/sentry/seer/entrypoints/slack/tasks.py index c9b4376816c7..1074d7751c9d 100644 --- a/src/sentry/seer/entrypoints/slack/tasks.py +++ b/src/sentry/seer/entrypoints/slack/tasks.py @@ -14,6 +14,7 @@ from sentry.integrations.services.integration.service import integration_service from sentry.models.organization import Organization from sentry.notifications.platform.slack.provider import SlackRenderable +from sentry.seer.credits import SeerFeature, SeerOperation, can_run_seer_operation from sentry.seer.entrypoints.metrics import ( SlackEntrypointEventLifecycleMetric, SlackEntrypointInteractionType, @@ -160,6 +161,19 @@ def process_mention_for_slack( ) return + # Check credits before fetching context or starting a run. + if not can_run_seer_operation( + organization, SeerFeature.EXPLORER, SeerOperation.AGENT_CHAT_MESSAGE + ): + lifecycle.record_halt(ProcessMentionHaltReason.OUT_OF_SEER_CREDITS) + # The webhook set "is thinking..." before queueing this task; clear it. + entrypoint.install.set_thread_status( + channel_id=channel_id, + thread_ts=thread_ts or ts, + status="", + ) + return + prompt = extract_prompt(text, bot_user_id) linked_result = _resolve_linked_messages( diff --git a/tests/sentry/seer/entrypoints/slack/test_tasks.py b/tests/sentry/seer/entrypoints/slack/test_tasks.py index 2891851e0f72..97d1f137c0dc 100644 --- a/tests/sentry/seer/entrypoints/slack/test_tasks.py +++ b/tests/sentry/seer/entrypoints/slack/test_tasks.py @@ -8,6 +8,7 @@ from django.test import override_settings from sentry.integrations.messaging.metrics import SeerSlackHaltReason +from sentry.seer.credits import SeerFeature, SeerOperation from sentry.seer.entrypoints.slack.analytics import ( SlackSeerAgentConversation, SlackSeerAgentFeedback, @@ -401,6 +402,105 @@ def test_skips_analytics_when_trigger_agent_fails( assert_not_analytics_event(mock_record, SlackSeerAgentResponded) +class ProcessMentionSeerCreditsTest(TestCase): + def setUp(self) -> None: + super().setUp() + self.mock_entrypoint = MagicMock() + self.mock_entrypoint.thread_ts = "1234567890.123456" + self.mock_entrypoint.install.get_thread_history.return_value = [] + + def _run_task( + self, *, user_id: int, mock_agent_cls: MagicMock, mock_resolve_user: MagicMock + ) -> None: + mock_resolve_user.return_value = MagicMock(id=user_id, username="alice") + mock_agent_cls.return_value = self.mock_entrypoint + process_mention_for_slack(**{**TASK_KWARGS, "organization_id": self.organization.id}) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch("sentry.seer.entrypoints.slack.tasks.can_run_seer_operation", return_value=False) + @patch("sentry.seer.entrypoints.slack.tasks.SeerAgentOperator") + @patch("sentry.seer.entrypoints.slack.tasks.SlackAgentEntrypoint") + @patch("sentry.seer.entrypoints.slack.tasks._resolve_user") + def test_out_of_credits_halts_without_running( + self, + mock_resolve_user: MagicMock, + mock_agent_cls: MagicMock, + mock_operator_cls: MagicMock, + mock_can_run: MagicMock, + mock_record: MagicMock, + ) -> None: + mock_operator_cls.has_access.return_value = True + + self._run_task( + user_id=self.user.id, mock_agent_cls=mock_agent_cls, mock_resolve_user=mock_resolve_user + ) + + mock_can_run.assert_called_once_with( + self.organization, SeerFeature.EXPLORER, SeerOperation.AGENT_CHAT_MESSAGE + ) + mock_operator_cls.assert_not_called() + self.mock_entrypoint.install.get_thread_history.assert_not_called() + self.mock_entrypoint.install.set_thread_status.assert_called_once_with( + channel_id="C1234567890", + thread_ts="1234567890.123456", + status="", + ) + assert_halt_metric(mock_record, ProcessMentionHaltReason.OUT_OF_SEER_CREDITS) + + @patch("sentry.seer.credits.seer_credit.backend.check_seer_credits") + @patch("sentry.seer.entrypoints.slack.tasks.SeerAgentOperator") + @patch("sentry.seer.entrypoints.slack.tasks.SlackAgentEntrypoint") + @patch("sentry.seer.entrypoints.slack.tasks._resolve_user") + def test_credit_check_flag_on_with_enough_credits_runs( + self, + mock_resolve_user: MagicMock, + mock_agent_cls: MagicMock, + mock_operator_cls: MagicMock, + mock_check: MagicMock, + ) -> None: + mock_operator_cls.has_access.return_value = True + mock_operator_cls.return_value.trigger_agent.return_value = None + mock_check.return_value = MagicMock(has_enough_credits=True) + + with self.feature( + ["organizations:seer-credit-checks", "organizations:token-billed-seer-enabled"] + ): + self._run_task( + user_id=self.user.id, + mock_agent_cls=mock_agent_cls, + mock_resolve_user=mock_resolve_user, + ) + + mock_check.assert_called_once() + mock_operator_cls.return_value.trigger_agent.assert_called_once() + + @patch("sentry.seer.credits.seer_credit.backend.check_seer_credits") + @patch("sentry.seer.entrypoints.slack.tasks.SeerAgentOperator") + @patch("sentry.seer.entrypoints.slack.tasks.SlackAgentEntrypoint") + @patch("sentry.seer.entrypoints.slack.tasks._resolve_user") + def test_credit_check_flag_on_without_enough_credits_halts( + self, + mock_resolve_user: MagicMock, + mock_agent_cls: MagicMock, + mock_operator_cls: MagicMock, + mock_check: MagicMock, + ) -> None: + mock_operator_cls.has_access.return_value = True + mock_check.return_value = MagicMock(has_enough_credits=False) + + with self.feature( + ["organizations:seer-credit-checks", "organizations:token-billed-seer-enabled"] + ): + self._run_task( + user_id=self.user.id, + mock_agent_cls=mock_agent_cls, + mock_resolve_user=mock_resolve_user, + ) + + mock_check.assert_called_once() + mock_operator_cls.assert_not_called() + + class LinkedMessagesContextTest(TestCase): """Tests for the Slack-permalink → on_page_context wiring."""