From 5f3ac31063dbf2e216eda42ee4110ac31c4dcb0a Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 9 Nov 2026 10:41:33 -0500 Subject: [PATCH 1/2] feat(slack): Check Seer credits before running an agent chat mention Co-Authored-By: Claude Opus 5.5 --- src/sentry/seer/entrypoints/slack/metrics.py | 1 + src/sentry/seer/entrypoints/slack/tasks.py | 12 +++ .../seer/entrypoints/slack/test_tasks.py | 98 +++++++++++++++++++ 3 files changed, 111 insertions(+) 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..eb086d4b2a27 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 SeerOperation, can_run_seer_operation from sentry.seer.entrypoints.metrics import ( SlackEntrypointEventLifecycleMetric, SlackEntrypointInteractionType, @@ -160,6 +161,17 @@ def process_mention_for_slack( ) return + # Check credits before fetching context or starting a run. + if not can_run_seer_operation(organization, 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..8ef62d956a1b 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 SeerOperation from sentry.seer.entrypoints.slack.analytics import ( SlackSeerAgentConversation, SlackSeerAgentFeedback, @@ -401,6 +402,103 @@ 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, 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.""" From 0d60c3dae267e40b9c5ecccbdd39c28c164015ac Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 10 Nov 2026 15:40:45 -0500 Subject: [PATCH 2/2] ref(slack): Check agent chat credits under the explorer feature can_run_seer_operation now takes the Seer feature to bill against. Slack mentions run the agent chat, so they pass the explorer feature like the web chat endpoint does. Co-Authored-By: Claude Fable 5.2 --- src/sentry/seer/entrypoints/slack/tasks.py | 6 ++++-- tests/sentry/seer/entrypoints/slack/test_tasks.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/sentry/seer/entrypoints/slack/tasks.py b/src/sentry/seer/entrypoints/slack/tasks.py index eb086d4b2a27..1074d7751c9d 100644 --- a/src/sentry/seer/entrypoints/slack/tasks.py +++ b/src/sentry/seer/entrypoints/slack/tasks.py @@ -14,7 +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 SeerOperation, can_run_seer_operation +from sentry.seer.credits import SeerFeature, SeerOperation, can_run_seer_operation from sentry.seer.entrypoints.metrics import ( SlackEntrypointEventLifecycleMetric, SlackEntrypointInteractionType, @@ -162,7 +162,9 @@ def process_mention_for_slack( return # Check credits before fetching context or starting a run. - if not can_run_seer_operation(organization, SeerOperation.AGENT_CHAT_MESSAGE): + 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( diff --git a/tests/sentry/seer/entrypoints/slack/test_tasks.py b/tests/sentry/seer/entrypoints/slack/test_tasks.py index 8ef62d956a1b..97d1f137c0dc 100644 --- a/tests/sentry/seer/entrypoints/slack/test_tasks.py +++ b/tests/sentry/seer/entrypoints/slack/test_tasks.py @@ -8,7 +8,7 @@ from django.test import override_settings from sentry.integrations.messaging.metrics import SeerSlackHaltReason -from sentry.seer.credits import SeerOperation +from sentry.seer.credits import SeerFeature, SeerOperation from sentry.seer.entrypoints.slack.analytics import ( SlackSeerAgentConversation, SlackSeerAgentFeedback, @@ -435,7 +435,9 @@ def test_out_of_credits_halts_without_running( 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, SeerOperation.AGENT_CHAT_MESSAGE) + 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(