From fea7e452b37b27a1526e259f3b72c9958cfe88a2 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 9 Nov 2026 15:21:53 -0500 Subject: [PATCH 01/11] feat(notifications): Add an out-of-credits notification for Seer Autofix Adds SeerAutofixOutOfCredits with its template, notification source and Slack rendering, and a has_credits_for_next_step flag on SeerAutofixUpdate that hides the next-step button when it is False. Co-Authored-By: Claude Opus 5.5 --- .../platform/slack/renderers/seer.py | 10 +++- .../notifications/platform/templates/seer.py | 28 +++++++++ src/sentry/notifications/platform/types.py | 2 + .../platform/slack/renderers/test_seer.py | 60 +++++++++++++++++++ .../platform/templates/test_seer.py | 16 +++++ tests/sentry/notifications/test_apps.py | 1 + 6 files changed, 116 insertions(+), 1 deletion(-) diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 0012542bb4d4..febbaba5bb0c 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -28,9 +28,11 @@ from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.seer import ( + AUTOFIX_OUT_OF_CREDITS_MESSAGE, SeerAgentError, SeerAgentResponse, SeerAutofixError, + SeerAutofixOutOfCredits, SeerAutofixStopped, SeerAutofixTrigger, SeerAutofixUpdate, @@ -106,6 +108,7 @@ class AutofixStageConfig(TypedDict): NotificationSource.SEER_AUTOFIX_STOPPED, NotificationSource.SEER_AUTOFIX_SUCCESS, NotificationSource.SEER_AUTOFIX_UPDATE, + NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS, NotificationSource.SEER_AGENT_RESPONSE, NotificationSource.SEER_AGENT_ERROR, NotificationSource.SEER_INVESTIGATION_STARTED, @@ -142,6 +145,11 @@ def render[DataT: NotificationData]( ) elif isinstance(data, SeerAutofixUpdate): return cls._render_autofix_update(data, link_decorator) + elif isinstance(data, SeerAutofixOutOfCredits): + return SlackRenderable( + blocks=[SectionBlock(text=AUTOFIX_OUT_OF_CREDITS_MESSAGE)], + text=AUTOFIX_OUT_OF_CREDITS_MESSAGE, + ) elif isinstance(data, SeerAgentError): return cls._render_agent_error(data) elif isinstance(data, SeerAgentResponse): @@ -232,7 +240,7 @@ def _render_autofix_update( project_id=data.project_id, ) ) - elif data.has_next_trigger: + elif data.has_next_trigger and data.has_credits_for_next_step: action_elements.append( cls._render_autofix_button(data=SeerAutofixTrigger.from_update(data)) ) diff --git a/src/sentry/notifications/platform/templates/seer.py b/src/sentry/notifications/platform/templates/seer.py index 54f20af93201..bc8f26164700 100644 --- a/src/sentry/notifications/platform/templates/seer.py +++ b/src/sentry/notifications/platform/templates/seer.py @@ -82,6 +82,33 @@ def render(self, data: SeerAutofixStopped) -> NotificationRenderedTemplate: ) +AUTOFIX_OUT_OF_CREDITS_MESSAGE = "Your org does not have enough credits to continue your Autofix run. Set up auto top up to keep your flow uninterrupted." + + +class SeerAutofixOutOfCredits(NotificationData): + # False: the org tried to start a Seer step without credits. + # True: the org ran out of credits partway through a run. + started: bool + source: NotificationSource = NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS + + +@template_registry.register(NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS) +class SeerAutofixOutOfCreditsTemplate(NotificationTemplate[SeerAutofixOutOfCredits]): + category = NotificationCategory.SEER + example_data = SeerAutofixOutOfCredits( + source=NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS, + organization_id=1, + started=False, + ) + hide_from_debugger = True + + def render(self, data: SeerAutofixOutOfCredits) -> NotificationRenderedTemplate: + return NotificationRenderedTemplate( + subject=AUTOFIX_OUT_OF_CREDITS_MESSAGE, + body=[ParagraphSection(blocks=[PlainTextBlock(text=AUTOFIX_OUT_OF_CREDITS_MESSAGE)])], + ) + + class SeerAutofixCodeChange(TypedDict): repo_name: str diff: str @@ -108,6 +135,7 @@ class SeerAutofixUpdate(NotificationData): pull_requests: list[SeerAutofixPullRequest] = Field(default_factory=list) summary: str | None = None handoff_target: CodingAgentProviderType | None = None + has_credits_for_next_step: bool = True source: NotificationSource = NotificationSource.SEER_AUTOFIX_UPDATE @property diff --git a/src/sentry/notifications/platform/types.py b/src/sentry/notifications/platform/types.py index 3d3ae1307c48..806a273f4851 100644 --- a/src/sentry/notifications/platform/types.py +++ b/src/sentry/notifications/platform/types.py @@ -70,6 +70,7 @@ class NotificationSource(StrEnum): SEER_AUTOFIX_TRIGGER = "seer-autofix-trigger" SEER_AUTOFIX_FOOTER = "seer-autofix-footer" SEER_AUTOFIX_SUCCESS = "seer-autofix-success" + SEER_AUTOFIX_OUT_OF_CREDITS = "seer-autofix-out-of-credits" SEER_AGENT_RESPONSE = "seer-agent-response" SEER_AGENT_ERROR = "seer-agent-error" SEER_AGENT_WRITE_APPROVAL = "seer-agent-write-approval" @@ -139,6 +140,7 @@ class NotificationSource(StrEnum): NotificationSource.SEER_AUTOFIX_STOPPED, NotificationSource.SEER_AUTOFIX_SUCCESS, NotificationSource.SEER_AUTOFIX_UPDATE, + NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS, NotificationSource.SEER_AGENT_RESPONSE, NotificationSource.SEER_AGENT_ERROR, NotificationSource.SEER_AGENT_WRITE_APPROVAL, diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index 4d89849566fd..36b61ce93d84 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -25,10 +25,13 @@ SeerAgentWriteApprovalSlackRenderer, ) from sentry.notifications.platform.templates.seer import ( + AUTOFIX_OUT_OF_CREDITS_MESSAGE, SeerAgentError, SeerAgentResponse, SeerAgentWriteApproval, SeerAutofixCodeChange, + SeerAutofixOutOfCredits, + SeerAutofixOutOfCreditsTemplate, SeerAutofixPullRequest, SeerAutofixUpdate, SeerInvestigationConclusionTemplate, @@ -232,6 +235,32 @@ def test_render_autofix_update_no_progress_with_next_trigger( assert isinstance(actions_block.elements[1], ButtonElement) assert actions_block.elements[1].value == AutofixStoppingPoint.SOLUTION.value + def test_render_autofix_update_without_credits_has_no_next_trigger(self) -> None: + data = self._create_update( + current_point=AutofixStoppingPoint.ROOT_CAUSE, + summary="Test summary", + ).copy(update={"has_credits_for_next_step": False}) + renderable = SeerSlackRenderer._render_autofix_update(data, link_decorator(data)) + actions_blocks = [b for b in renderable["blocks"] if isinstance(b, ActionsBlock)] + assert len(actions_blocks) == 1 + # Only the link to Sentry is left + assert len(actions_blocks[0].elements) == 1 + assert isinstance(actions_blocks[0].elements[0], LinkButtonElement) + + @patch( + "sentry.notifications.platform.templates.seer.organization_service.get_option", + return_value=False, + ) + def test_render_autofix_update_solution_coding_disabled_has_no_next_trigger( + self, _mock_get_option: Mock + ) -> None: + data = self._create_update(current_point=AutofixStoppingPoint.SOLUTION) + renderable = SeerSlackRenderer._render_autofix_update(data, link_decorator(data)) + actions_blocks = [b for b in renderable["blocks"] if isinstance(b, ActionsBlock)] + assert len(actions_blocks) == 1 + assert len(actions_blocks[0].elements) == 1 + assert isinstance(actions_blocks[0].elements[0], LinkButtonElement) + @patch( "sentry.notifications.platform.templates.seer.organization_service.get_option", return_value=True, @@ -297,6 +326,21 @@ def test_render_autofix_update_root_cause_with_handoff_target(self) -> None: assert handoff_button.action_id is not None assert handoff_button.action_id.startswith("seer_autofix_handoff::") + def test_render_autofix_update_handoff_shown_without_credits(self) -> None: + data = self._create_update( + current_point=AutofixStoppingPoint.ROOT_CAUSE, + handoff_target="cursor_background_agent", + ).copy(update={"has_credits_for_next_step": False}) + renderable = SeerSlackRenderer._render_autofix_update(data, link_decorator(data)) + + actions_block = next((b for b in renderable["blocks"] if isinstance(b, ActionsBlock)), None) + assert actions_block is not None + assert len(actions_block.elements) == 2 + handoff_button = actions_block.elements[1] + assert isinstance(handoff_button, ButtonElement) + assert handoff_button.action_id is not None + assert handoff_button.action_id.startswith("seer_autofix_handoff::") + def test_render_autofix_update_root_cause_with_reasoning(self) -> None: data = self._create_update( current_point=AutofixStoppingPoint.ROOT_CAUSE, @@ -331,6 +375,22 @@ def test_render_autofix_update_solution_no_next_trigger_when_coding_disabled( assert isinstance(actions_block.elements[0], LinkButtonElement) +class SeerSlackRendererAutofixOutOfCreditsTest(TestCase): + def test_render_out_of_credits(self) -> None: + for started in (False, True): + data = SeerAutofixOutOfCredits(organization_id=1, started=started) + renderable = render_seer( + data=data, rendered_template=SeerAutofixOutOfCreditsTemplate().render(data) + ) + + assert renderable["text"] == AUTOFIX_OUT_OF_CREDITS_MESSAGE + blocks = renderable["blocks"] + assert len(blocks) == 1 + assert isinstance(blocks[0], SectionBlock) + assert blocks[0].text is not None + assert blocks[0].text.text == AUTOFIX_OUT_OF_CREDITS_MESSAGE + + class SeerSlackRendererAgentErrorTest(TestCase): def test_render_agent_error(self) -> None: data = SeerAgentError( diff --git a/tests/sentry/notifications/platform/templates/test_seer.py b/tests/sentry/notifications/platform/templates/test_seer.py index e4c761a6be3a..9a560bfe2dca 100644 --- a/tests/sentry/notifications/platform/templates/test_seer.py +++ b/tests/sentry/notifications/platform/templates/test_seer.py @@ -5,7 +5,10 @@ from fixtures.seer.webhooks import MOCK_GROUP_ID, MOCK_RUN_ID from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.notifications.platform.templates.seer import ( + AUTOFIX_OUT_OF_CREDITS_MESSAGE, SeerAgentError, + SeerAutofixOutOfCredits, + SeerAutofixOutOfCreditsTemplate, SeerAutofixTrigger, SeerAutofixUpdate, SeerInvestigationConclusionTemplate, @@ -142,6 +145,19 @@ def test_render_custom_title(self) -> None: assert rendered.body[0].blocks[0].text == "Timeout." +class SeerAutofixOutOfCreditsTemplateTest(TestCase): + def test_render(self) -> None: + for started in (False, True): + rendered = SeerAutofixOutOfCreditsTemplate().render( + SeerAutofixOutOfCredits(organization_id=1, started=started) + ) + + assert rendered.subject == AUTOFIX_OUT_OF_CREDITS_MESSAGE + assert len(rendered.body) == 1 + assert isinstance(rendered.body[0], ParagraphSection) + assert rendered.body[0].blocks[0].text == AUTOFIX_OUT_OF_CREDITS_MESSAGE + + class SeerInvestigationStartedTemplateTest(TestCase): def test_render(self) -> None: rendered = SeerInvestigationStartedTemplate().render( diff --git a/tests/sentry/notifications/test_apps.py b/tests/sentry/notifications/test_apps.py index 37010ded24ba..822a13b5bc10 100644 --- a/tests/sentry/notifications/test_apps.py +++ b/tests/sentry/notifications/test_apps.py @@ -57,4 +57,5 @@ def test_registers_platform_renderers(self) -> None: (NotificationProviderKey.SLACK, NotificationSource.SEER_INVESTIGATION_STARTED), (NotificationProviderKey.SLACK, NotificationSource.SEER_INVESTIGATION_STATUS), (NotificationProviderKey.SLACK, NotificationSource.SEER_INVESTIGATION_CONCLUSION), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS), } From 1c9d9426cfbec6c97782026987f26107dbba8454 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 9 Nov 2026 15:27:22 -0500 Subject: [PATCH 02/11] feat(notifications): Add an out-of-credits notification for Seer Agent Adds SeerAgentOutOfCredits with its template, notification source and Slack rendering. The PR status line under an agent answer now says the org is out of Seer credits when a push failed for that reason. Moves the OUT_OF_CREDITS constant here so the renderer can use it. Co-Authored-By: Claude Opus 5.5 --- .../platform/slack/renderers/seer.py | 11 +++++++ .../notifications/platform/templates/seer.py | 20 +++++++++++++ src/sentry/notifications/platform/types.py | 2 ++ src/sentry/seer/credits.py | 3 ++ .../platform/slack/renderers/test_seer.py | 29 +++++++++++++++++++ .../platform/templates/test_seer.py | 13 +++++++++ tests/sentry/notifications/test_apps.py | 1 + 7 files changed, 79 insertions(+) diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index febbaba5bb0c..60c9b7d59337 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -28,8 +28,10 @@ from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.seer import ( + AGENT_OUT_OF_CREDITS_MESSAGE, AUTOFIX_OUT_OF_CREDITS_MESSAGE, SeerAgentError, + SeerAgentOutOfCredits, SeerAgentResponse, SeerAutofixError, SeerAutofixOutOfCredits, @@ -50,6 +52,7 @@ ) from sentry.seer.agent.client_models import RepoPRState from sentry.seer.autofix.utils import AutofixStoppingPoint, CodingAgentProviderType +from sentry.seer.credits import OUT_OF_CREDITS if TYPE_CHECKING: from sentry.models.group import Group @@ -111,6 +114,7 @@ class AutofixStageConfig(TypedDict): NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS, NotificationSource.SEER_AGENT_RESPONSE, NotificationSource.SEER_AGENT_ERROR, + NotificationSource.SEER_AGENT_OUT_OF_CREDITS, NotificationSource.SEER_INVESTIGATION_STARTED, NotificationSource.SEER_INVESTIGATION_ERROR, NotificationSource.SEER_INVESTIGATION_STATUS, @@ -154,6 +158,11 @@ def render[DataT: NotificationData]( return cls._render_agent_error(data) elif isinstance(data, SeerAgentResponse): return cls._render_agent_response(data, link_decorator) + elif isinstance(data, SeerAgentOutOfCredits): + return SlackRenderable( + blocks=[SectionBlock(text=AGENT_OUT_OF_CREDITS_MESSAGE)], + text=AGENT_OUT_OF_CREDITS_MESSAGE, + ) elif isinstance(data, SeerInvestigationStarted): return cls._render_investigation_started(data) elif isinstance(data, SeerInvestigationError): @@ -471,6 +480,8 @@ def _render_agent_pr_state(pr: RepoPRState) -> str: return ( f"Updating {pr_text}..." if pr.pr_number else f"Creating a PR in {repo_text}..." ) + case "error" if pr.pr_creation_error_code == OUT_OF_CREDITS: + return f"Couldn't push to {pr_text}: your organization is out of Seer credits" case "error": return f"Latest push failed for {pr_text}" case "completed": diff --git a/src/sentry/notifications/platform/templates/seer.py b/src/sentry/notifications/platform/templates/seer.py index bc8f26164700..07b15e1ff20c 100644 --- a/src/sentry/notifications/platform/templates/seer.py +++ b/src/sentry/notifications/platform/templates/seer.py @@ -244,6 +244,26 @@ def render(self, data: SeerAgentError) -> NotificationRenderedTemplate: ) +AGENT_OUT_OF_CREDITS_MESSAGE = "Your org does not have enough Seer credits for this request. Set up auto top up to keep your flow uninterrupted." + + +class SeerAgentOutOfCredits(NotificationData): + source: NotificationSource = NotificationSource.SEER_AGENT_OUT_OF_CREDITS + + +@template_registry.register(NotificationSource.SEER_AGENT_OUT_OF_CREDITS) +class SeerAgentOutOfCreditsTemplate(NotificationTemplate[SeerAgentOutOfCredits]): + category = NotificationCategory.SEER + example_data = SeerAgentOutOfCredits(organization_id=1) + hide_from_debugger = True + + def render(self, data: SeerAgentOutOfCredits) -> NotificationRenderedTemplate: + return NotificationRenderedTemplate( + subject=AGENT_OUT_OF_CREDITS_MESSAGE, + body=[ParagraphSection(blocks=[PlainTextBlock(text=AGENT_OUT_OF_CREDITS_MESSAGE)])], + ) + + class SeerAgentResponse(NotificationData): """Notification data for Agent completion response in Slack.""" diff --git a/src/sentry/notifications/platform/types.py b/src/sentry/notifications/platform/types.py index 806a273f4851..f52312dc1631 100644 --- a/src/sentry/notifications/platform/types.py +++ b/src/sentry/notifications/platform/types.py @@ -74,6 +74,7 @@ class NotificationSource(StrEnum): SEER_AGENT_RESPONSE = "seer-agent-response" SEER_AGENT_ERROR = "seer-agent-error" SEER_AGENT_WRITE_APPROVAL = "seer-agent-write-approval" + SEER_AGENT_OUT_OF_CREDITS = "seer-agent-out-of-credits" SEER_INVESTIGATION_STARTED = "seer-investigation-started" SEER_INVESTIGATION_ERROR = "seer-investigation-error" SEER_INVESTIGATION_STATUS = "seer-investigation-status" @@ -144,6 +145,7 @@ class NotificationSource(StrEnum): NotificationSource.SEER_AGENT_RESPONSE, NotificationSource.SEER_AGENT_ERROR, NotificationSource.SEER_AGENT_WRITE_APPROVAL, + NotificationSource.SEER_AGENT_OUT_OF_CREDITS, NotificationSource.SEER_INVESTIGATION_STARTED, NotificationSource.SEER_INVESTIGATION_ERROR, NotificationSource.SEER_INVESTIGATION_STATUS, diff --git a/src/sentry/seer/credits.py b/src/sentry/seer/credits.py index bbb1e0da7ee9..cab2f106f286 100644 --- a/src/sentry/seer/credits.py +++ b/src/sentry/seer/credits.py @@ -11,6 +11,9 @@ from sentry import features, seer_credit from sentry.models.organization import Organization +# Seer's failure_reason and pr_creation_error_code when the org ran out of credits. +OUT_OF_CREDITS = "out_of_credits" + class SeerOperation(StrEnum): AGENT_CHAT_MESSAGE = "agent_chat_message" diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index 36b61ce93d84..8e312b8c5273 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -25,8 +25,11 @@ SeerAgentWriteApprovalSlackRenderer, ) from sentry.notifications.platform.templates.seer import ( + AGENT_OUT_OF_CREDITS_MESSAGE, AUTOFIX_OUT_OF_CREDITS_MESSAGE, SeerAgentError, + SeerAgentOutOfCredits, + SeerAgentOutOfCreditsTemplate, SeerAgentResponse, SeerAgentWriteApproval, SeerAutofixCodeChange, @@ -49,6 +52,7 @@ NotificationProviderKey, NotificationRenderedTemplate, ) +from sentry.seer.agent.client_models import RepoPRState from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.testutils.cases import TestCase from sentry.testutils.helpers.options import override_options @@ -391,6 +395,21 @@ def test_render_out_of_credits(self) -> None: assert blocks[0].text.text == AUTOFIX_OUT_OF_CREDITS_MESSAGE +class SeerSlackRendererAgentOutOfCreditsTest(TestCase): + def test_render_out_of_credits(self) -> None: + data = SeerAgentOutOfCredits(organization_id=1) + renderable = render_seer( + data=data, rendered_template=SeerAgentOutOfCreditsTemplate().render(data) + ) + + assert renderable["text"] == AGENT_OUT_OF_CREDITS_MESSAGE + blocks = renderable["blocks"] + assert len(blocks) == 1 + assert isinstance(blocks[0], SectionBlock) + assert blocks[0].text is not None + assert blocks[0].text.text == AGENT_OUT_OF_CREDITS_MESSAGE + + class SeerSlackRendererAgentErrorTest(TestCase): def test_render_agent_error(self) -> None: data = SeerAgentError( @@ -514,6 +533,16 @@ def test_render_agent_response_with_missing_scope_footer(self) -> None: assert "Reinstall" in footer_text assert "I can't read the whole thread" in footer_text + def test_render_agent_pr_state_out_of_credits(self) -> None: + pr = RepoPRState( + repo_name="acme/web", + pr_creation_status="error", + pr_creation_error_code="out_of_credits", + ) + assert SeerSlackRenderer._render_agent_pr_state(pr) == ( + "Couldn't push to `acme/web`: your organization is out of Seer credits" + ) + def test_render_agent_response_no_footer_when_url_not_set(self) -> None: data = self._create_agent_response(summary="Some analysis") assert data.missing_scope_settings_url is None diff --git a/tests/sentry/notifications/platform/templates/test_seer.py b/tests/sentry/notifications/platform/templates/test_seer.py index 9a560bfe2dca..cfb958041fb9 100644 --- a/tests/sentry/notifications/platform/templates/test_seer.py +++ b/tests/sentry/notifications/platform/templates/test_seer.py @@ -5,8 +5,11 @@ from fixtures.seer.webhooks import MOCK_GROUP_ID, MOCK_RUN_ID from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.notifications.platform.templates.seer import ( + AGENT_OUT_OF_CREDITS_MESSAGE, AUTOFIX_OUT_OF_CREDITS_MESSAGE, SeerAgentError, + SeerAgentOutOfCredits, + SeerAgentOutOfCreditsTemplate, SeerAutofixOutOfCredits, SeerAutofixOutOfCreditsTemplate, SeerAutofixTrigger, @@ -158,6 +161,16 @@ def test_render(self) -> None: assert rendered.body[0].blocks[0].text == AUTOFIX_OUT_OF_CREDITS_MESSAGE +class SeerAgentOutOfCreditsTemplateTest(TestCase): + def test_render(self) -> None: + rendered = SeerAgentOutOfCreditsTemplate().render(SeerAgentOutOfCredits(organization_id=1)) + + assert rendered.subject == AGENT_OUT_OF_CREDITS_MESSAGE + assert len(rendered.body) == 1 + assert isinstance(rendered.body[0], ParagraphSection) + assert rendered.body[0].blocks[0].text == AGENT_OUT_OF_CREDITS_MESSAGE + + class SeerInvestigationStartedTemplateTest(TestCase): def test_render(self) -> None: rendered = SeerInvestigationStartedTemplate().render( diff --git a/tests/sentry/notifications/test_apps.py b/tests/sentry/notifications/test_apps.py index 822a13b5bc10..27e285c8a88b 100644 --- a/tests/sentry/notifications/test_apps.py +++ b/tests/sentry/notifications/test_apps.py @@ -58,4 +58,5 @@ def test_registers_platform_renderers(self) -> None: (NotificationProviderKey.SLACK, NotificationSource.SEER_INVESTIGATION_STATUS), (NotificationProviderKey.SLACK, NotificationSource.SEER_INVESTIGATION_CONCLUSION), (NotificationProviderKey.SLACK, NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AGENT_OUT_OF_CREDITS), } From e8eb56b13b75d6843bd7efc9af46204d9e5122d0 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 9 Nov 2026 16:06:33 -0500 Subject: [PATCH 03/11] feat(notifications): Link out-of-credits Slack replies to billing Add a "Manage Seer credits" button to the Autofix and Agent out-of-credits messages. There is no top-up or request-credits page yet, so it opens the billing overview, where billing members can set up auto top up. Co-Authored-By: Claude Opus 5.5 --- .../slack/message_builder/types.py | 1 + .../integrations/slack/webhooks/action.py | 1 + .../platform/slack/renderers/seer.py | 55 +++++++++++++++-- .../platform/slack/renderers/test_seer.py | 61 ++++++++++++++----- 4 files changed, 97 insertions(+), 21 deletions(-) diff --git a/src/sentry/integrations/slack/message_builder/types.py b/src/sentry/integrations/slack/message_builder/types.py index 10c348ddce43..a0e165eff388 100644 --- a/src/sentry/integrations/slack/message_builder/types.py +++ b/src/sentry/integrations/slack/message_builder/types.py @@ -33,6 +33,7 @@ class SlackAction(StrEnum): SEER_AGENT_WRITE_APPROVE = "seer_agent_write_approve" SEER_AGENT_WRITE_REJECT = "seer_agent_write_reject" SEER_INVESTIGATION_START = "seer_investigation_start" + SEER_VIEW_BILLING = "seer_view_billing" INCIDENT_COLOR_MAPPING = { diff --git a/src/sentry/integrations/slack/webhooks/action.py b/src/sentry/integrations/slack/webhooks/action.py index bc7280a9618f..713970f4db11 100644 --- a/src/sentry/integrations/slack/webhooks/action.py +++ b/src/sentry/integrations/slack/webhooks/action.py @@ -893,6 +893,7 @@ def post(self, request: Request) -> Response: SlackAction.SEER_AUTOFIX_VIEW_IN_SENTRY.value, SlackAction.SEER_AUTOFIX_VIEW_PR.value, SlackAction.SEER_INVESTIGATION_START.value, + SlackAction.SEER_VIEW_BILLING.value, }: return self.respond() diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 60c9b7d59337..fcf18fd74860 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -150,18 +150,20 @@ def render[DataT: NotificationData]( elif isinstance(data, SeerAutofixUpdate): return cls._render_autofix_update(data, link_decorator) elif isinstance(data, SeerAutofixOutOfCredits): - return SlackRenderable( - blocks=[SectionBlock(text=AUTOFIX_OUT_OF_CREDITS_MESSAGE)], - text=AUTOFIX_OUT_OF_CREDITS_MESSAGE, + return cls._render_out_of_credits( + message=AUTOFIX_OUT_OF_CREDITS_MESSAGE, + organization_id=data.organization_id, + link_decorator=link_decorator, ) elif isinstance(data, SeerAgentError): return cls._render_agent_error(data) elif isinstance(data, SeerAgentResponse): return cls._render_agent_response(data, link_decorator) elif isinstance(data, SeerAgentOutOfCredits): - return SlackRenderable( - blocks=[SectionBlock(text=AGENT_OUT_OF_CREDITS_MESSAGE)], - text=AGENT_OUT_OF_CREDITS_MESSAGE, + return cls._render_out_of_credits( + message=AGENT_OUT_OF_CREDITS_MESSAGE, + organization_id=data.organization_id, + link_decorator=link_decorator, ) elif isinstance(data, SeerInvestigationStarted): return cls._render_investigation_started(data) @@ -217,6 +219,47 @@ def _render_handoff_button( ), ) + @classmethod + def _render_out_of_credits( + cls, + *, + message: str, + organization_id: int, + link_decorator: NotificationLinkDecorator, + ) -> SlackRenderable: + from sentry.integrations.slack.message_builder.routing import encode_action_id + from sentry.integrations.slack.message_builder.types import SlackAction + from sentry.models.organization import Organization + + blocks: list[Block] = [SectionBlock(text=message)] + try: + organization = Organization.objects.get_from_cache(id=organization_id) + except Organization.DoesNotExist: + organization = None + if organization is not None: + # There is no top-up or request-credits page yet, so send everyone + # to billing, where billing members can set up auto top up. + billing_url = organization.absolute_url( + f"/settings/{organization.slug}/billing/overview/" + ) + blocks.append( + ActionsBlock( + elements=[ + LinkButtonElement( + text="Manage Seer credits", + style="primary", + url=link_decorator.decorate_url(billing_url), + action_id=encode_action_id( + action=SlackAction.SEER_VIEW_BILLING.value, + organization_id=organization_id, + project_id=None, + ), + ) + ] + ) + ) + return SlackRenderable(blocks=blocks, text=message) + @classmethod def _render_autofix_error(cls, data: SeerAutofixError) -> SlackRenderable: return SlackRenderable( diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index 8e312b8c5273..2d98538a0238 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -379,35 +379,66 @@ def test_render_autofix_update_solution_no_next_trigger_when_coding_disabled( assert isinstance(actions_block.elements[0], LinkButtonElement) +def assert_out_of_credits_renderable( + renderable: SlackRenderable, *, message: str, organization_id: int, billing_slug: str +) -> None: + assert renderable["text"] == message + blocks = renderable["blocks"] + assert len(blocks) == 2 + assert isinstance(blocks[0], SectionBlock) + assert blocks[0].text is not None + assert blocks[0].text.text == message + + assert isinstance(blocks[1], ActionsBlock) + assert len(blocks[1].elements) == 1 + button = blocks[1].elements[0] + assert isinstance(button, LinkButtonElement) + assert button.text is not None + assert button.text.text == "Manage Seer credits" + assert button.url is not None + assert f"/settings/{billing_slug}/billing/overview/" in button.url + assert button.action_id is not None + routing = decode_action_id(button.action_id) + assert routing.action == SlackAction.SEER_VIEW_BILLING.value + assert routing.organization_id == organization_id + + class SeerSlackRendererAutofixOutOfCreditsTest(TestCase): def test_render_out_of_credits(self) -> None: for started in (False, True): - data = SeerAutofixOutOfCredits(organization_id=1, started=started) + data = SeerAutofixOutOfCredits(organization_id=self.organization.id, started=started) renderable = render_seer( data=data, rendered_template=SeerAutofixOutOfCreditsTemplate().render(data) ) + assert_out_of_credits_renderable( + renderable, + message=AUTOFIX_OUT_OF_CREDITS_MESSAGE, + organization_id=self.organization.id, + billing_slug=self.organization.slug, + ) - assert renderable["text"] == AUTOFIX_OUT_OF_CREDITS_MESSAGE - blocks = renderable["blocks"] - assert len(blocks) == 1 - assert isinstance(blocks[0], SectionBlock) - assert blocks[0].text is not None - assert blocks[0].text.text == AUTOFIX_OUT_OF_CREDITS_MESSAGE + def test_render_out_of_credits_missing_organization(self) -> None: + data = SeerAutofixOutOfCredits(organization_id=0, started=False) + renderable = render_seer( + data=data, rendered_template=SeerAutofixOutOfCreditsTemplate().render(data) + ) + assert renderable["text"] == AUTOFIX_OUT_OF_CREDITS_MESSAGE + assert len(renderable["blocks"]) == 1 + assert isinstance(renderable["blocks"][0], SectionBlock) class SeerSlackRendererAgentOutOfCreditsTest(TestCase): def test_render_out_of_credits(self) -> None: - data = SeerAgentOutOfCredits(organization_id=1) + data = SeerAgentOutOfCredits(organization_id=self.organization.id) renderable = render_seer( data=data, rendered_template=SeerAgentOutOfCreditsTemplate().render(data) ) - - assert renderable["text"] == AGENT_OUT_OF_CREDITS_MESSAGE - blocks = renderable["blocks"] - assert len(blocks) == 1 - assert isinstance(blocks[0], SectionBlock) - assert blocks[0].text is not None - assert blocks[0].text.text == AGENT_OUT_OF_CREDITS_MESSAGE + assert_out_of_credits_renderable( + renderable, + message=AGENT_OUT_OF_CREDITS_MESSAGE, + organization_id=self.organization.id, + billing_slug=self.organization.slug, + ) class SeerSlackRendererAgentErrorTest(TestCase): From 568005e0e59f2686e27deaba9cee0674f3c8e4b7 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 9 Nov 2026 17:10:27 -0500 Subject: [PATCH 04/11] ref(notifications): Update Seer out-of-credits Slack copy Use the product copy for the Autofix and Agent out-of-credits messages, and label the billing button "Configure autoreload". Autofix now has separate copy for a run that could not start and one that ran out of credits partway through. Co-Authored-By: Claude Fable 5.2 --- .../notifications/platform/slack/renderers/seer.py | 8 +++----- src/sentry/notifications/platform/templates/seer.py | 13 ++++++++++--- .../platform/slack/renderers/test_seer.py | 12 ++++++++---- .../notifications/platform/templates/test_seer.py | 10 +++++++--- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index fcf18fd74860..164f0b355a37 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -29,7 +29,6 @@ from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.seer import ( AGENT_OUT_OF_CREDITS_MESSAGE, - AUTOFIX_OUT_OF_CREDITS_MESSAGE, SeerAgentError, SeerAgentOutOfCredits, SeerAgentResponse, @@ -151,7 +150,7 @@ def render[DataT: NotificationData]( return cls._render_autofix_update(data, link_decorator) elif isinstance(data, SeerAutofixOutOfCredits): return cls._render_out_of_credits( - message=AUTOFIX_OUT_OF_CREDITS_MESSAGE, + message=data.message, organization_id=data.organization_id, link_decorator=link_decorator, ) @@ -237,8 +236,7 @@ def _render_out_of_credits( except Organization.DoesNotExist: organization = None if organization is not None: - # There is no top-up or request-credits page yet, so send everyone - # to billing, where billing members can set up auto top up. + # TODO(alexsohn): change this to top-up or request credits link billing_url = organization.absolute_url( f"/settings/{organization.slug}/billing/overview/" ) @@ -246,7 +244,7 @@ def _render_out_of_credits( ActionsBlock( elements=[ LinkButtonElement( - text="Manage Seer credits", + text="Configure autoreload", style="primary", url=link_decorator.decorate_url(billing_url), action_id=encode_action_id( diff --git a/src/sentry/notifications/platform/templates/seer.py b/src/sentry/notifications/platform/templates/seer.py index 07b15e1ff20c..222140a435de 100644 --- a/src/sentry/notifications/platform/templates/seer.py +++ b/src/sentry/notifications/platform/templates/seer.py @@ -82,6 +82,7 @@ def render(self, data: SeerAutofixStopped) -> NotificationRenderedTemplate: ) +AUTOFIX_OUT_OF_CREDITS_START_MESSAGE = "You do not have enough credits to start an Autofix run. Set up auto top up to keep your flow uninterrupted." AUTOFIX_OUT_OF_CREDITS_MESSAGE = "Your org does not have enough credits to continue your Autofix run. Set up auto top up to keep your flow uninterrupted." @@ -91,6 +92,12 @@ class SeerAutofixOutOfCredits(NotificationData): started: bool source: NotificationSource = NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS + @property + def message(self) -> str: + return ( + AUTOFIX_OUT_OF_CREDITS_MESSAGE if self.started else AUTOFIX_OUT_OF_CREDITS_START_MESSAGE + ) + @template_registry.register(NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS) class SeerAutofixOutOfCreditsTemplate(NotificationTemplate[SeerAutofixOutOfCredits]): @@ -104,8 +111,8 @@ class SeerAutofixOutOfCreditsTemplate(NotificationTemplate[SeerAutofixOutOfCredi def render(self, data: SeerAutofixOutOfCredits) -> NotificationRenderedTemplate: return NotificationRenderedTemplate( - subject=AUTOFIX_OUT_OF_CREDITS_MESSAGE, - body=[ParagraphSection(blocks=[PlainTextBlock(text=AUTOFIX_OUT_OF_CREDITS_MESSAGE)])], + subject=data.message, + body=[ParagraphSection(blocks=[PlainTextBlock(text=data.message)])], ) @@ -244,7 +251,7 @@ def render(self, data: SeerAgentError) -> NotificationRenderedTemplate: ) -AGENT_OUT_OF_CREDITS_MESSAGE = "Your org does not have enough Seer credits for this request. Set up auto top up to keep your flow uninterrupted." +AGENT_OUT_OF_CREDITS_MESSAGE = "Your organization does not have enough credits left for you to message Seer, and you have no auto top-ups remaining. Adjust your spend and turn on auto top-up." class SeerAgentOutOfCredits(NotificationData): diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index 2d98538a0238..f0bdcbeadced 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -27,6 +27,7 @@ from sentry.notifications.platform.templates.seer import ( AGENT_OUT_OF_CREDITS_MESSAGE, AUTOFIX_OUT_OF_CREDITS_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_START_MESSAGE, SeerAgentError, SeerAgentOutOfCredits, SeerAgentOutOfCreditsTemplate, @@ -394,7 +395,7 @@ def assert_out_of_credits_renderable( button = blocks[1].elements[0] assert isinstance(button, LinkButtonElement) assert button.text is not None - assert button.text.text == "Manage Seer credits" + assert button.text.text == "Configure autoreload" assert button.url is not None assert f"/settings/{billing_slug}/billing/overview/" in button.url assert button.action_id is not None @@ -405,14 +406,17 @@ def assert_out_of_credits_renderable( class SeerSlackRendererAutofixOutOfCreditsTest(TestCase): def test_render_out_of_credits(self) -> None: - for started in (False, True): + for started, message in ( + (False, AUTOFIX_OUT_OF_CREDITS_START_MESSAGE), + (True, AUTOFIX_OUT_OF_CREDITS_MESSAGE), + ): data = SeerAutofixOutOfCredits(organization_id=self.organization.id, started=started) renderable = render_seer( data=data, rendered_template=SeerAutofixOutOfCreditsTemplate().render(data) ) assert_out_of_credits_renderable( renderable, - message=AUTOFIX_OUT_OF_CREDITS_MESSAGE, + message=message, organization_id=self.organization.id, billing_slug=self.organization.slug, ) @@ -422,7 +426,7 @@ def test_render_out_of_credits_missing_organization(self) -> None: renderable = render_seer( data=data, rendered_template=SeerAutofixOutOfCreditsTemplate().render(data) ) - assert renderable["text"] == AUTOFIX_OUT_OF_CREDITS_MESSAGE + assert renderable["text"] == AUTOFIX_OUT_OF_CREDITS_START_MESSAGE assert len(renderable["blocks"]) == 1 assert isinstance(renderable["blocks"][0], SectionBlock) diff --git a/tests/sentry/notifications/platform/templates/test_seer.py b/tests/sentry/notifications/platform/templates/test_seer.py index cfb958041fb9..1b03bfda746d 100644 --- a/tests/sentry/notifications/platform/templates/test_seer.py +++ b/tests/sentry/notifications/platform/templates/test_seer.py @@ -7,6 +7,7 @@ from sentry.notifications.platform.templates.seer import ( AGENT_OUT_OF_CREDITS_MESSAGE, AUTOFIX_OUT_OF_CREDITS_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_START_MESSAGE, SeerAgentError, SeerAgentOutOfCredits, SeerAgentOutOfCreditsTemplate, @@ -150,15 +151,18 @@ def test_render_custom_title(self) -> None: class SeerAutofixOutOfCreditsTemplateTest(TestCase): def test_render(self) -> None: - for started in (False, True): + for started, message in ( + (False, AUTOFIX_OUT_OF_CREDITS_START_MESSAGE), + (True, AUTOFIX_OUT_OF_CREDITS_MESSAGE), + ): rendered = SeerAutofixOutOfCreditsTemplate().render( SeerAutofixOutOfCredits(organization_id=1, started=started) ) - assert rendered.subject == AUTOFIX_OUT_OF_CREDITS_MESSAGE + assert rendered.subject == message assert len(rendered.body) == 1 assert isinstance(rendered.body[0], ParagraphSection) - assert rendered.body[0].blocks[0].text == AUTOFIX_OUT_OF_CREDITS_MESSAGE + assert rendered.body[0].blocks[0].text == message class SeerAgentOutOfCreditsTemplateTest(TestCase): From 0fe8b6e8839e0367f4a807375259139cd4997932 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 10 Nov 2026 13:00:19 -0500 Subject: [PATCH 05/11] ref(notifications): Always show the next autofix step in Slack Drop has_credits_for_next_step from the autofix update. The next-step button is shown whether or not the org has Seer credits; a step that can't run fails in Seer and the thread gets the out-of-credits reply. Co-Authored-By: Claude Fable 5.2 --- .../platform/slack/renderers/seer.py | 2 +- .../notifications/platform/templates/seer.py | 1 - .../platform/slack/renderers/test_seer.py | 41 ------------------- 3 files changed, 1 insertion(+), 43 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 164f0b355a37..4fb2efb0f43a 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -290,7 +290,7 @@ def _render_autofix_update( project_id=data.project_id, ) ) - elif data.has_next_trigger and data.has_credits_for_next_step: + elif data.has_next_trigger: action_elements.append( cls._render_autofix_button(data=SeerAutofixTrigger.from_update(data)) ) diff --git a/src/sentry/notifications/platform/templates/seer.py b/src/sentry/notifications/platform/templates/seer.py index 222140a435de..e18e52045e00 100644 --- a/src/sentry/notifications/platform/templates/seer.py +++ b/src/sentry/notifications/platform/templates/seer.py @@ -142,7 +142,6 @@ class SeerAutofixUpdate(NotificationData): pull_requests: list[SeerAutofixPullRequest] = Field(default_factory=list) summary: str | None = None handoff_target: CodingAgentProviderType | None = None - has_credits_for_next_step: bool = True source: NotificationSource = NotificationSource.SEER_AUTOFIX_UPDATE @property diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index f0bdcbeadced..f0d3cf9d1531 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -240,32 +240,6 @@ def test_render_autofix_update_no_progress_with_next_trigger( assert isinstance(actions_block.elements[1], ButtonElement) assert actions_block.elements[1].value == AutofixStoppingPoint.SOLUTION.value - def test_render_autofix_update_without_credits_has_no_next_trigger(self) -> None: - data = self._create_update( - current_point=AutofixStoppingPoint.ROOT_CAUSE, - summary="Test summary", - ).copy(update={"has_credits_for_next_step": False}) - renderable = SeerSlackRenderer._render_autofix_update(data, link_decorator(data)) - actions_blocks = [b for b in renderable["blocks"] if isinstance(b, ActionsBlock)] - assert len(actions_blocks) == 1 - # Only the link to Sentry is left - assert len(actions_blocks[0].elements) == 1 - assert isinstance(actions_blocks[0].elements[0], LinkButtonElement) - - @patch( - "sentry.notifications.platform.templates.seer.organization_service.get_option", - return_value=False, - ) - def test_render_autofix_update_solution_coding_disabled_has_no_next_trigger( - self, _mock_get_option: Mock - ) -> None: - data = self._create_update(current_point=AutofixStoppingPoint.SOLUTION) - renderable = SeerSlackRenderer._render_autofix_update(data, link_decorator(data)) - actions_blocks = [b for b in renderable["blocks"] if isinstance(b, ActionsBlock)] - assert len(actions_blocks) == 1 - assert len(actions_blocks[0].elements) == 1 - assert isinstance(actions_blocks[0].elements[0], LinkButtonElement) - @patch( "sentry.notifications.platform.templates.seer.organization_service.get_option", return_value=True, @@ -331,21 +305,6 @@ def test_render_autofix_update_root_cause_with_handoff_target(self) -> None: assert handoff_button.action_id is not None assert handoff_button.action_id.startswith("seer_autofix_handoff::") - def test_render_autofix_update_handoff_shown_without_credits(self) -> None: - data = self._create_update( - current_point=AutofixStoppingPoint.ROOT_CAUSE, - handoff_target="cursor_background_agent", - ).copy(update={"has_credits_for_next_step": False}) - renderable = SeerSlackRenderer._render_autofix_update(data, link_decorator(data)) - - actions_block = next((b for b in renderable["blocks"] if isinstance(b, ActionsBlock)), None) - assert actions_block is not None - assert len(actions_block.elements) == 2 - handoff_button = actions_block.elements[1] - assert isinstance(handoff_button, ButtonElement) - assert handoff_button.action_id is not None - assert handoff_button.action_id.startswith("seer_autofix_handoff::") - def test_render_autofix_update_root_cause_with_reasoning(self) -> None: data = self._create_update( current_point=AutofixStoppingPoint.ROOT_CAUSE, From 30770548cb480120500fedc617dab2260cff56ba Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 10 Nov 2026 14:24:56 -0500 Subject: [PATCH 06/11] ref(notifications): Drop started from the autofix out-of-credits notification Nothing sends the "start an Autofix run" copy, so remove the started attribute, the message property and the unused constant. The notification always renders the one out-of-credits message. Co-Authored-By: Claude Fable 5.2 --- .../platform/slack/renderers/seer.py | 3 +- .../notifications/platform/templates/seer.py | 15 ++-------- .../platform/slack/renderers/test_seer.py | 29 ++++++++----------- .../platform/templates/test_seer.py | 19 +++++------- 4 files changed, 23 insertions(+), 43 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 4fb2efb0f43a..e27ca0276544 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -29,6 +29,7 @@ from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.seer import ( AGENT_OUT_OF_CREDITS_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_MESSAGE, SeerAgentError, SeerAgentOutOfCredits, SeerAgentResponse, @@ -150,7 +151,7 @@ def render[DataT: NotificationData]( return cls._render_autofix_update(data, link_decorator) elif isinstance(data, SeerAutofixOutOfCredits): return cls._render_out_of_credits( - message=data.message, + message=AUTOFIX_OUT_OF_CREDITS_MESSAGE, organization_id=data.organization_id, link_decorator=link_decorator, ) diff --git a/src/sentry/notifications/platform/templates/seer.py b/src/sentry/notifications/platform/templates/seer.py index e18e52045e00..16f194b400da 100644 --- a/src/sentry/notifications/platform/templates/seer.py +++ b/src/sentry/notifications/platform/templates/seer.py @@ -82,22 +82,12 @@ def render(self, data: SeerAutofixStopped) -> NotificationRenderedTemplate: ) -AUTOFIX_OUT_OF_CREDITS_START_MESSAGE = "You do not have enough credits to start an Autofix run. Set up auto top up to keep your flow uninterrupted." AUTOFIX_OUT_OF_CREDITS_MESSAGE = "Your org does not have enough credits to continue your Autofix run. Set up auto top up to keep your flow uninterrupted." class SeerAutofixOutOfCredits(NotificationData): - # False: the org tried to start a Seer step without credits. - # True: the org ran out of credits partway through a run. - started: bool source: NotificationSource = NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS - @property - def message(self) -> str: - return ( - AUTOFIX_OUT_OF_CREDITS_MESSAGE if self.started else AUTOFIX_OUT_OF_CREDITS_START_MESSAGE - ) - @template_registry.register(NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS) class SeerAutofixOutOfCreditsTemplate(NotificationTemplate[SeerAutofixOutOfCredits]): @@ -105,14 +95,13 @@ class SeerAutofixOutOfCreditsTemplate(NotificationTemplate[SeerAutofixOutOfCredi example_data = SeerAutofixOutOfCredits( source=NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS, organization_id=1, - started=False, ) hide_from_debugger = True def render(self, data: SeerAutofixOutOfCredits) -> NotificationRenderedTemplate: return NotificationRenderedTemplate( - subject=data.message, - body=[ParagraphSection(blocks=[PlainTextBlock(text=data.message)])], + subject=AUTOFIX_OUT_OF_CREDITS_MESSAGE, + body=[ParagraphSection(blocks=[PlainTextBlock(text=AUTOFIX_OUT_OF_CREDITS_MESSAGE)])], ) diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index f0d3cf9d1531..47a5f69c47eb 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -27,7 +27,6 @@ from sentry.notifications.platform.templates.seer import ( AGENT_OUT_OF_CREDITS_MESSAGE, AUTOFIX_OUT_OF_CREDITS_MESSAGE, - AUTOFIX_OUT_OF_CREDITS_START_MESSAGE, SeerAgentError, SeerAgentOutOfCredits, SeerAgentOutOfCreditsTemplate, @@ -365,27 +364,23 @@ def assert_out_of_credits_renderable( class SeerSlackRendererAutofixOutOfCreditsTest(TestCase): def test_render_out_of_credits(self) -> None: - for started, message in ( - (False, AUTOFIX_OUT_OF_CREDITS_START_MESSAGE), - (True, AUTOFIX_OUT_OF_CREDITS_MESSAGE), - ): - data = SeerAutofixOutOfCredits(organization_id=self.organization.id, started=started) - renderable = render_seer( - data=data, rendered_template=SeerAutofixOutOfCreditsTemplate().render(data) - ) - assert_out_of_credits_renderable( - renderable, - message=message, - organization_id=self.organization.id, - billing_slug=self.organization.slug, - ) + data = SeerAutofixOutOfCredits(organization_id=self.organization.id) + renderable = render_seer( + data=data, rendered_template=SeerAutofixOutOfCreditsTemplate().render(data) + ) + assert_out_of_credits_renderable( + renderable, + message=AUTOFIX_OUT_OF_CREDITS_MESSAGE, + organization_id=self.organization.id, + billing_slug=self.organization.slug, + ) def test_render_out_of_credits_missing_organization(self) -> None: - data = SeerAutofixOutOfCredits(organization_id=0, started=False) + data = SeerAutofixOutOfCredits(organization_id=0) renderable = render_seer( data=data, rendered_template=SeerAutofixOutOfCreditsTemplate().render(data) ) - assert renderable["text"] == AUTOFIX_OUT_OF_CREDITS_START_MESSAGE + assert renderable["text"] == AUTOFIX_OUT_OF_CREDITS_MESSAGE assert len(renderable["blocks"]) == 1 assert isinstance(renderable["blocks"][0], SectionBlock) diff --git a/tests/sentry/notifications/platform/templates/test_seer.py b/tests/sentry/notifications/platform/templates/test_seer.py index 1b03bfda746d..5639de8954e9 100644 --- a/tests/sentry/notifications/platform/templates/test_seer.py +++ b/tests/sentry/notifications/platform/templates/test_seer.py @@ -7,7 +7,6 @@ from sentry.notifications.platform.templates.seer import ( AGENT_OUT_OF_CREDITS_MESSAGE, AUTOFIX_OUT_OF_CREDITS_MESSAGE, - AUTOFIX_OUT_OF_CREDITS_START_MESSAGE, SeerAgentError, SeerAgentOutOfCredits, SeerAgentOutOfCreditsTemplate, @@ -151,18 +150,14 @@ def test_render_custom_title(self) -> None: class SeerAutofixOutOfCreditsTemplateTest(TestCase): def test_render(self) -> None: - for started, message in ( - (False, AUTOFIX_OUT_OF_CREDITS_START_MESSAGE), - (True, AUTOFIX_OUT_OF_CREDITS_MESSAGE), - ): - rendered = SeerAutofixOutOfCreditsTemplate().render( - SeerAutofixOutOfCredits(organization_id=1, started=started) - ) + rendered = SeerAutofixOutOfCreditsTemplate().render( + SeerAutofixOutOfCredits(organization_id=1) + ) - assert rendered.subject == message - assert len(rendered.body) == 1 - assert isinstance(rendered.body[0], ParagraphSection) - assert rendered.body[0].blocks[0].text == message + assert rendered.subject == AUTOFIX_OUT_OF_CREDITS_MESSAGE + assert len(rendered.body) == 1 + assert isinstance(rendered.body[0], ParagraphSection) + assert rendered.body[0].blocks[0].text == AUTOFIX_OUT_OF_CREDITS_MESSAGE class SeerAgentOutOfCreditsTemplateTest(TestCase): From 68128628c6c940221a6c36dd6469ae85d5b4f486 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 12 Nov 2026 14:13:12 -0500 Subject: [PATCH 07/11] ref(notifications): Use auto-reload wording in out-of-credits copy Apply the copy suggestions from review: say "auto-reload" instead of "auto top-up" and "autoreload", and "organization" instead of "org", in the Seer Autofix and Agent out-of-credits notifications. Co-Authored-By: Claude Fable 5.2 --- src/sentry/notifications/platform/slack/renderers/seer.py | 2 +- src/sentry/notifications/platform/templates/seer.py | 4 ++-- .../notifications/platform/slack/renderers/test_seer.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index e27ca0276544..66c0182f2db4 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -245,7 +245,7 @@ def _render_out_of_credits( ActionsBlock( elements=[ LinkButtonElement( - text="Configure autoreload", + text="Configure auto-reload", style="primary", url=link_decorator.decorate_url(billing_url), action_id=encode_action_id( diff --git a/src/sentry/notifications/platform/templates/seer.py b/src/sentry/notifications/platform/templates/seer.py index 16f194b400da..5638867a02a1 100644 --- a/src/sentry/notifications/platform/templates/seer.py +++ b/src/sentry/notifications/platform/templates/seer.py @@ -82,7 +82,7 @@ def render(self, data: SeerAutofixStopped) -> NotificationRenderedTemplate: ) -AUTOFIX_OUT_OF_CREDITS_MESSAGE = "Your org does not have enough credits to continue your Autofix run. Set up auto top up to keep your flow uninterrupted." +AUTOFIX_OUT_OF_CREDITS_MESSAGE = "Your organization does not have enough credits to continue your Autofix run. Set up auto-reload to keep your flow uninterrupted." class SeerAutofixOutOfCredits(NotificationData): @@ -239,7 +239,7 @@ def render(self, data: SeerAgentError) -> NotificationRenderedTemplate: ) -AGENT_OUT_OF_CREDITS_MESSAGE = "Your organization does not have enough credits left for you to message Seer, and you have no auto top-ups remaining. Adjust your spend and turn on auto top-up." +AGENT_OUT_OF_CREDITS_MESSAGE = "Your organization does not have enough credits left for you to message Seer, and you have no auto-reloads remaining. Adjust your spend and turn on auto-reload." class SeerAgentOutOfCredits(NotificationData): diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index 47a5f69c47eb..ff6dcbe46c82 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -353,7 +353,7 @@ def assert_out_of_credits_renderable( button = blocks[1].elements[0] assert isinstance(button, LinkButtonElement) assert button.text is not None - assert button.text.text == "Configure autoreload" + assert button.text.text == "Configure auto-reload" assert button.url is not None assert f"/settings/{billing_slug}/billing/overview/" in button.url assert button.action_id is not None From ff33b283751fa0be339c711aaac9659f309188d5 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 12 Nov 2026 15:50:01 -0500 Subject: [PATCH 08/11] feat(notifications): Vary out-of-credits copy by billing access and auto-reload The Seer Autofix and Agent out-of-credits notifications now take has_billing_access and auto_reload_enabled. Billing members are told to set up auto-reload, or that they have no auto-reloads remaining when it is already on, and get the "Configure auto-reload" button. Everyone else is told to ask an admin and gets no button. Both fields default to False, so a notification sent without knowing the reader uses the member copy. Co-Authored-By: Claude Fable 5.2 --- .../platform/slack/renderers/seer.py | 30 +++---- .../notifications/platform/templates/seer.py | 41 +++++++-- .../platform/slack/renderers/test_seer.py | 86 ++++++++++++++----- .../platform/templates/test_seer.py | 75 +++++++++++----- 4 files changed, 163 insertions(+), 69 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 66c0182f2db4..93d5759bce66 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -28,8 +28,6 @@ from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.seer import ( - AGENT_OUT_OF_CREDITS_MESSAGE, - AUTOFIX_OUT_OF_CREDITS_MESSAGE, SeerAgentError, SeerAgentOutOfCredits, SeerAgentResponse, @@ -149,22 +147,12 @@ def render[DataT: NotificationData]( ) elif isinstance(data, SeerAutofixUpdate): return cls._render_autofix_update(data, link_decorator) - elif isinstance(data, SeerAutofixOutOfCredits): - return cls._render_out_of_credits( - message=AUTOFIX_OUT_OF_CREDITS_MESSAGE, - organization_id=data.organization_id, - link_decorator=link_decorator, - ) + elif isinstance(data, (SeerAutofixOutOfCredits, SeerAgentOutOfCredits)): + return cls._render_out_of_credits(data, link_decorator) elif isinstance(data, SeerAgentError): return cls._render_agent_error(data) elif isinstance(data, SeerAgentResponse): return cls._render_agent_response(data, link_decorator) - elif isinstance(data, SeerAgentOutOfCredits): - return cls._render_out_of_credits( - message=AGENT_OUT_OF_CREDITS_MESSAGE, - organization_id=data.organization_id, - link_decorator=link_decorator, - ) elif isinstance(data, SeerInvestigationStarted): return cls._render_investigation_started(data) elif isinstance(data, SeerInvestigationError): @@ -222,22 +210,24 @@ def _render_handoff_button( @classmethod def _render_out_of_credits( cls, - *, - message: str, - organization_id: int, + data: SeerAutofixOutOfCredits | SeerAgentOutOfCredits, link_decorator: NotificationLinkDecorator, ) -> SlackRenderable: from sentry.integrations.slack.message_builder.routing import encode_action_id from sentry.integrations.slack.message_builder.types import SlackAction from sentry.models.organization import Organization + message = data.message blocks: list[Block] = [SectionBlock(text=message)] + # TODO(alexsohn): give members a button that asks their admins for credits. + if not data.has_billing_access: + return SlackRenderable(blocks=blocks, text=message) + try: - organization = Organization.objects.get_from_cache(id=organization_id) + organization = Organization.objects.get_from_cache(id=data.organization_id) except Organization.DoesNotExist: organization = None if organization is not None: - # TODO(alexsohn): change this to top-up or request credits link billing_url = organization.absolute_url( f"/settings/{organization.slug}/billing/overview/" ) @@ -250,7 +240,7 @@ def _render_out_of_credits( url=link_decorator.decorate_url(billing_url), action_id=encode_action_id( action=SlackAction.SEER_VIEW_BILLING.value, - organization_id=organization_id, + organization_id=data.organization_id, project_id=None, ), ) diff --git a/src/sentry/notifications/platform/templates/seer.py b/src/sentry/notifications/platform/templates/seer.py index 5638867a02a1..ca3fbaa3105b 100644 --- a/src/sentry/notifications/platform/templates/seer.py +++ b/src/sentry/notifications/platform/templates/seer.py @@ -82,11 +82,27 @@ def render(self, data: SeerAutofixStopped) -> NotificationRenderedTemplate: ) -AUTOFIX_OUT_OF_CREDITS_MESSAGE = "Your organization does not have enough credits to continue your Autofix run. Set up auto-reload to keep your flow uninterrupted." +# Out-of-credits copy depends on what the reader can do about it. Members can only ask an +# admin. Billing members can configure auto-reload, and if it is already on, the organization +# has used up the reloads it allows. +AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE = "Your organization does not have enough credits to continue your Autofix run. Ask your admin to increase your daily token limit or set up auto-reload." +AUTOFIX_OUT_OF_CREDITS_BILLING_MESSAGE = "Your organization does not have enough credits to continue your Autofix run. Set up auto-reload to keep your flow uninterrupted." +AUTOFIX_OUT_OF_CREDITS_NO_RELOADS_MESSAGE = "Your organization does not have enough credits to continue your Autofix run, and you have no auto-reloads remaining. Adjust your spend and turn on auto-reload." class SeerAutofixOutOfCredits(NotificationData): source: NotificationSource = NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS + has_billing_access: bool = False + """The reader can configure auto-reload. Leave False when the reader is unknown.""" + auto_reload_enabled: bool = False + + @property + def message(self) -> str: + if not self.has_billing_access: + return AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE + if self.auto_reload_enabled: + return AUTOFIX_OUT_OF_CREDITS_NO_RELOADS_MESSAGE + return AUTOFIX_OUT_OF_CREDITS_BILLING_MESSAGE @template_registry.register(NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS) @@ -100,8 +116,8 @@ class SeerAutofixOutOfCreditsTemplate(NotificationTemplate[SeerAutofixOutOfCredi def render(self, data: SeerAutofixOutOfCredits) -> NotificationRenderedTemplate: return NotificationRenderedTemplate( - subject=AUTOFIX_OUT_OF_CREDITS_MESSAGE, - body=[ParagraphSection(blocks=[PlainTextBlock(text=AUTOFIX_OUT_OF_CREDITS_MESSAGE)])], + subject=data.message, + body=[ParagraphSection(blocks=[PlainTextBlock(text=data.message)])], ) @@ -239,11 +255,24 @@ def render(self, data: SeerAgentError) -> NotificationRenderedTemplate: ) -AGENT_OUT_OF_CREDITS_MESSAGE = "Your organization does not have enough credits left for you to message Seer, and you have no auto-reloads remaining. Adjust your spend and turn on auto-reload." +AGENT_OUT_OF_CREDITS_MEMBER_MESSAGE = "Your organization does not have enough credits left for you to message Seer. Ask your admin to set up auto-reload." +AGENT_OUT_OF_CREDITS_BILLING_MESSAGE = "Your organization does not have enough credits left for you to message Seer. Adjust your spend and turn on auto-reload." +AGENT_OUT_OF_CREDITS_NO_RELOADS_MESSAGE = "Your organization does not have enough credits left for you to message Seer, and you have no auto-reloads remaining. Adjust your spend and turn on auto-reload." class SeerAgentOutOfCredits(NotificationData): source: NotificationSource = NotificationSource.SEER_AGENT_OUT_OF_CREDITS + has_billing_access: bool = False + """The reader can configure auto-reload. Leave False when the reader is unknown.""" + auto_reload_enabled: bool = False + + @property + def message(self) -> str: + if not self.has_billing_access: + return AGENT_OUT_OF_CREDITS_MEMBER_MESSAGE + if self.auto_reload_enabled: + return AGENT_OUT_OF_CREDITS_NO_RELOADS_MESSAGE + return AGENT_OUT_OF_CREDITS_BILLING_MESSAGE @template_registry.register(NotificationSource.SEER_AGENT_OUT_OF_CREDITS) @@ -254,8 +283,8 @@ class SeerAgentOutOfCreditsTemplate(NotificationTemplate[SeerAgentOutOfCredits]) def render(self, data: SeerAgentOutOfCredits) -> NotificationRenderedTemplate: return NotificationRenderedTemplate( - subject=AGENT_OUT_OF_CREDITS_MESSAGE, - body=[ParagraphSection(blocks=[PlainTextBlock(text=AGENT_OUT_OF_CREDITS_MESSAGE)])], + subject=data.message, + body=[ParagraphSection(blocks=[PlainTextBlock(text=data.message)])], ) diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index ff6dcbe46c82..ad3f68750d41 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -25,8 +25,12 @@ SeerAgentWriteApprovalSlackRenderer, ) from sentry.notifications.platform.templates.seer import ( - AGENT_OUT_OF_CREDITS_MESSAGE, - AUTOFIX_OUT_OF_CREDITS_MESSAGE, + AGENT_OUT_OF_CREDITS_BILLING_MESSAGE, + AGENT_OUT_OF_CREDITS_MEMBER_MESSAGE, + AGENT_OUT_OF_CREDITS_NO_RELOADS_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_BILLING_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_NO_RELOADS_MESSAGE, SeerAgentError, SeerAgentOutOfCredits, SeerAgentOutOfCreditsTemplate, @@ -338,16 +342,19 @@ def test_render_autofix_update_solution_no_next_trigger_when_coding_disabled( assert isinstance(actions_block.elements[0], LinkButtonElement) -def assert_out_of_credits_renderable( - renderable: SlackRenderable, *, message: str, organization_id: int, billing_slug: str -) -> None: +def assert_out_of_credits_message(renderable: SlackRenderable, message: str) -> None: assert renderable["text"] == message + section = renderable["blocks"][0] + assert isinstance(section, SectionBlock) + assert section.text is not None + assert section.text.text == message + + +def assert_configure_auto_reload_button( + renderable: SlackRenderable, *, organization_id: int, billing_slug: str +) -> None: blocks = renderable["blocks"] assert len(blocks) == 2 - assert isinstance(blocks[0], SectionBlock) - assert blocks[0].text is not None - assert blocks[0].text.text == message - assert isinstance(blocks[1], ActionsBlock) assert len(blocks[1].elements) == 1 button = blocks[1].elements[0] @@ -363,37 +370,70 @@ def assert_out_of_credits_renderable( class SeerSlackRendererAutofixOutOfCreditsTest(TestCase): - def test_render_out_of_credits(self) -> None: - data = SeerAutofixOutOfCredits(organization_id=self.organization.id) - renderable = render_seer( + def _render(self, **kwargs: bool) -> SlackRenderable: + data = SeerAutofixOutOfCredits(organization_id=self.organization.id, **kwargs) + return render_seer( data=data, rendered_template=SeerAutofixOutOfCreditsTemplate().render(data) ) - assert_out_of_credits_renderable( + + def test_member_gets_no_button(self) -> None: + renderable = self._render() + assert_out_of_credits_message(renderable, AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE) + assert len(renderable["blocks"]) == 1 + + def test_billing_member_can_configure_auto_reload(self) -> None: + renderable = self._render(has_billing_access=True) + assert_out_of_credits_message(renderable, AUTOFIX_OUT_OF_CREDITS_BILLING_MESSAGE) + assert_configure_auto_reload_button( renderable, - message=AUTOFIX_OUT_OF_CREDITS_MESSAGE, organization_id=self.organization.id, billing_slug=self.organization.slug, ) - def test_render_out_of_credits_missing_organization(self) -> None: - data = SeerAutofixOutOfCredits(organization_id=0) + def test_billing_member_with_auto_reload_on(self) -> None: + renderable = self._render(has_billing_access=True, auto_reload_enabled=True) + assert_out_of_credits_message(renderable, AUTOFIX_OUT_OF_CREDITS_NO_RELOADS_MESSAGE) + assert_configure_auto_reload_button( + renderable, + organization_id=self.organization.id, + billing_slug=self.organization.slug, + ) + + def test_missing_organization(self) -> None: + data = SeerAutofixOutOfCredits(organization_id=0, has_billing_access=True) renderable = render_seer( data=data, rendered_template=SeerAutofixOutOfCreditsTemplate().render(data) ) - assert renderable["text"] == AUTOFIX_OUT_OF_CREDITS_MESSAGE + assert_out_of_credits_message(renderable, AUTOFIX_OUT_OF_CREDITS_BILLING_MESSAGE) assert len(renderable["blocks"]) == 1 - assert isinstance(renderable["blocks"][0], SectionBlock) class SeerSlackRendererAgentOutOfCreditsTest(TestCase): - def test_render_out_of_credits(self) -> None: - data = SeerAgentOutOfCredits(organization_id=self.organization.id) - renderable = render_seer( + def _render(self, **kwargs: bool) -> SlackRenderable: + data = SeerAgentOutOfCredits(organization_id=self.organization.id, **kwargs) + return render_seer( data=data, rendered_template=SeerAgentOutOfCreditsTemplate().render(data) ) - assert_out_of_credits_renderable( + + def test_member_gets_no_button(self) -> None: + renderable = self._render() + assert_out_of_credits_message(renderable, AGENT_OUT_OF_CREDITS_MEMBER_MESSAGE) + assert len(renderable["blocks"]) == 1 + + def test_billing_member_can_configure_auto_reload(self) -> None: + renderable = self._render(has_billing_access=True) + assert_out_of_credits_message(renderable, AGENT_OUT_OF_CREDITS_BILLING_MESSAGE) + assert_configure_auto_reload_button( + renderable, + organization_id=self.organization.id, + billing_slug=self.organization.slug, + ) + + def test_billing_member_with_auto_reload_on(self) -> None: + renderable = self._render(has_billing_access=True, auto_reload_enabled=True) + assert_out_of_credits_message(renderable, AGENT_OUT_OF_CREDITS_NO_RELOADS_MESSAGE) + assert_configure_auto_reload_button( renderable, - message=AGENT_OUT_OF_CREDITS_MESSAGE, organization_id=self.organization.id, billing_slug=self.organization.slug, ) diff --git a/tests/sentry/notifications/platform/templates/test_seer.py b/tests/sentry/notifications/platform/templates/test_seer.py index 5639de8954e9..e60d055d78fb 100644 --- a/tests/sentry/notifications/platform/templates/test_seer.py +++ b/tests/sentry/notifications/platform/templates/test_seer.py @@ -5,8 +5,12 @@ from fixtures.seer.webhooks import MOCK_GROUP_ID, MOCK_RUN_ID from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.notifications.platform.templates.seer import ( - AGENT_OUT_OF_CREDITS_MESSAGE, - AUTOFIX_OUT_OF_CREDITS_MESSAGE, + AGENT_OUT_OF_CREDITS_BILLING_MESSAGE, + AGENT_OUT_OF_CREDITS_MEMBER_MESSAGE, + AGENT_OUT_OF_CREDITS_NO_RELOADS_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_BILLING_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_NO_RELOADS_MESSAGE, SeerAgentError, SeerAgentOutOfCredits, SeerAgentOutOfCreditsTemplate, @@ -148,26 +152,57 @@ def test_render_custom_title(self) -> None: assert rendered.body[0].blocks[0].text == "Timeout." -class SeerAutofixOutOfCreditsTemplateTest(TestCase): - def test_render(self) -> None: - rendered = SeerAutofixOutOfCreditsTemplate().render( - SeerAutofixOutOfCredits(organization_id=1) +@pytest.mark.parametrize( + ("has_billing_access", "auto_reload_enabled", "message"), + [ + (False, False, AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE), + # Members can't change auto-reload, so its state doesn't change their copy. + (False, True, AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE), + (True, False, AUTOFIX_OUT_OF_CREDITS_BILLING_MESSAGE), + (True, True, AUTOFIX_OUT_OF_CREDITS_NO_RELOADS_MESSAGE), + ], +) +def test_autofix_out_of_credits_template( + has_billing_access: bool, auto_reload_enabled: bool, message: str +) -> None: + rendered = SeerAutofixOutOfCreditsTemplate().render( + SeerAutofixOutOfCredits( + organization_id=1, + has_billing_access=has_billing_access, + auto_reload_enabled=auto_reload_enabled, ) + ) + + assert rendered.subject == message + assert len(rendered.body) == 1 + assert isinstance(rendered.body[0], ParagraphSection) + assert rendered.body[0].blocks[0].text == message + + +@pytest.mark.parametrize( + ("has_billing_access", "auto_reload_enabled", "message"), + [ + (False, False, AGENT_OUT_OF_CREDITS_MEMBER_MESSAGE), + (False, True, AGENT_OUT_OF_CREDITS_MEMBER_MESSAGE), + (True, False, AGENT_OUT_OF_CREDITS_BILLING_MESSAGE), + (True, True, AGENT_OUT_OF_CREDITS_NO_RELOADS_MESSAGE), + ], +) +def test_agent_out_of_credits_template( + has_billing_access: bool, auto_reload_enabled: bool, message: str +) -> None: + rendered = SeerAgentOutOfCreditsTemplate().render( + SeerAgentOutOfCredits( + organization_id=1, + has_billing_access=has_billing_access, + auto_reload_enabled=auto_reload_enabled, + ) + ) - assert rendered.subject == AUTOFIX_OUT_OF_CREDITS_MESSAGE - assert len(rendered.body) == 1 - assert isinstance(rendered.body[0], ParagraphSection) - assert rendered.body[0].blocks[0].text == AUTOFIX_OUT_OF_CREDITS_MESSAGE - - -class SeerAgentOutOfCreditsTemplateTest(TestCase): - def test_render(self) -> None: - rendered = SeerAgentOutOfCreditsTemplate().render(SeerAgentOutOfCredits(organization_id=1)) - - assert rendered.subject == AGENT_OUT_OF_CREDITS_MESSAGE - assert len(rendered.body) == 1 - assert isinstance(rendered.body[0], ParagraphSection) - assert rendered.body[0].blocks[0].text == AGENT_OUT_OF_CREDITS_MESSAGE + assert rendered.subject == message + assert len(rendered.body) == 1 + assert isinstance(rendered.body[0], ParagraphSection) + assert rendered.body[0].blocks[0].text == message class SeerInvestigationStartedTemplateTest(TestCase): From 0a76152a3122b3b1f17c866ddc5faf8c0916f308 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 12 Nov 2026 15:53:01 -0500 Subject: [PATCH 09/11] ref(notifications): Trim comments on the out-of-credits templates Co-Authored-By: Claude Fable 5.2 --- src/sentry/notifications/platform/templates/seer.py | 6 +----- tests/sentry/notifications/platform/templates/test_seer.py | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/sentry/notifications/platform/templates/seer.py b/src/sentry/notifications/platform/templates/seer.py index ca3fbaa3105b..de2962bd094e 100644 --- a/src/sentry/notifications/platform/templates/seer.py +++ b/src/sentry/notifications/platform/templates/seer.py @@ -82,9 +82,7 @@ def render(self, data: SeerAutofixStopped) -> NotificationRenderedTemplate: ) -# Out-of-credits copy depends on what the reader can do about it. Members can only ask an -# admin. Billing members can configure auto-reload, and if it is already on, the organization -# has used up the reloads it allows. +# NO_RELOADS is for billing members whose auto-reload is already on. AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE = "Your organization does not have enough credits to continue your Autofix run. Ask your admin to increase your daily token limit or set up auto-reload." AUTOFIX_OUT_OF_CREDITS_BILLING_MESSAGE = "Your organization does not have enough credits to continue your Autofix run. Set up auto-reload to keep your flow uninterrupted." AUTOFIX_OUT_OF_CREDITS_NO_RELOADS_MESSAGE = "Your organization does not have enough credits to continue your Autofix run, and you have no auto-reloads remaining. Adjust your spend and turn on auto-reload." @@ -93,7 +91,6 @@ def render(self, data: SeerAutofixStopped) -> NotificationRenderedTemplate: class SeerAutofixOutOfCredits(NotificationData): source: NotificationSource = NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS has_billing_access: bool = False - """The reader can configure auto-reload. Leave False when the reader is unknown.""" auto_reload_enabled: bool = False @property @@ -263,7 +260,6 @@ def render(self, data: SeerAgentError) -> NotificationRenderedTemplate: class SeerAgentOutOfCredits(NotificationData): source: NotificationSource = NotificationSource.SEER_AGENT_OUT_OF_CREDITS has_billing_access: bool = False - """The reader can configure auto-reload. Leave False when the reader is unknown.""" auto_reload_enabled: bool = False @property diff --git a/tests/sentry/notifications/platform/templates/test_seer.py b/tests/sentry/notifications/platform/templates/test_seer.py index e60d055d78fb..51d005b45710 100644 --- a/tests/sentry/notifications/platform/templates/test_seer.py +++ b/tests/sentry/notifications/platform/templates/test_seer.py @@ -156,7 +156,6 @@ def test_render_custom_title(self) -> None: ("has_billing_access", "auto_reload_enabled", "message"), [ (False, False, AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE), - # Members can't change auto-reload, so its state doesn't change their copy. (False, True, AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE), (True, False, AUTOFIX_OUT_OF_CREDITS_BILLING_MESSAGE), (True, True, AUTOFIX_OUT_OF_CREDITS_NO_RELOADS_MESSAGE), From c24565a12659694a79de16b9a12df74694cc6ac7 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 12 Nov 2026 16:11:21 -0500 Subject: [PATCH 10/11] feat(notifications): Add a Contact Admin button to member out-of-credits replies Readers without billing access now get a "Contact Admin" button on the Seer Autofix and Agent out-of-credits notifications. The click is acknowledged and does nothing yet; the handler that emails the billing admins comes later. Co-Authored-By: Claude Fable 5.2 --- .../slack/message_builder/types.py | 1 + .../integrations/slack/webhooks/action.py | 1 + .../platform/slack/renderers/seer.py | 16 ++++++++++++- .../actions/test_seer_request_credits.py | 23 ++++++++++++++++++ .../platform/slack/renderers/test_seer.py | 24 +++++++++++++++---- 5 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 tests/sentry/integrations/slack/webhooks/actions/test_seer_request_credits.py diff --git a/src/sentry/integrations/slack/message_builder/types.py b/src/sentry/integrations/slack/message_builder/types.py index a0e165eff388..8e51ca7b0d45 100644 --- a/src/sentry/integrations/slack/message_builder/types.py +++ b/src/sentry/integrations/slack/message_builder/types.py @@ -34,6 +34,7 @@ class SlackAction(StrEnum): SEER_AGENT_WRITE_REJECT = "seer_agent_write_reject" SEER_INVESTIGATION_START = "seer_investigation_start" SEER_VIEW_BILLING = "seer_view_billing" + SEER_REQUEST_CREDITS = "seer_request_credits" INCIDENT_COLOR_MAPPING = { diff --git a/src/sentry/integrations/slack/webhooks/action.py b/src/sentry/integrations/slack/webhooks/action.py index 713970f4db11..4d9dbcac37a2 100644 --- a/src/sentry/integrations/slack/webhooks/action.py +++ b/src/sentry/integrations/slack/webhooks/action.py @@ -894,6 +894,7 @@ def post(self, request: Request) -> Response: SlackAction.SEER_AUTOFIX_VIEW_PR.value, SlackAction.SEER_INVESTIGATION_START.value, SlackAction.SEER_VIEW_BILLING.value, + SlackAction.SEER_REQUEST_CREDITS.value, }: return self.respond() diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 93d5759bce66..61f76052d480 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -219,8 +219,22 @@ def _render_out_of_credits( message = data.message blocks: list[Block] = [SectionBlock(text=message)] - # TODO(alexsohn): give members a button that asks their admins for credits. if not data.has_billing_access: + # TODO(alexsohn): the click does nothing until the handler emails the admins. + blocks.append( + ActionsBlock( + elements=[ + ButtonElement( + text="Contact Admin", + action_id=encode_action_id( + action=SlackAction.SEER_REQUEST_CREDITS.value, + organization_id=data.organization_id, + project_id=None, + ), + ) + ] + ) + ) return SlackRenderable(blocks=blocks, text=message) try: diff --git a/tests/sentry/integrations/slack/webhooks/actions/test_seer_request_credits.py b/tests/sentry/integrations/slack/webhooks/actions/test_seer_request_credits.py new file mode 100644 index 000000000000..b4285a57a7cb --- /dev/null +++ b/tests/sentry/integrations/slack/webhooks/actions/test_seer_request_credits.py @@ -0,0 +1,23 @@ +from sentry.integrations.slack.message_builder.routing import encode_action_id +from sentry.integrations.slack.message_builder.types import SlackAction +from tests.sentry.integrations.slack.webhooks.actions import BaseEventTest + + +class SeerRequestCreditsTest(BaseEventTest): + def test_click_is_acknowledged(self) -> None: + resp = self.post_webhook_block_kit( + action_data=[ + { + "type": "button", + "action_id": encode_action_id( + action=SlackAction.SEER_REQUEST_CREDITS, + organization_id=self.organization.id, + project_id=None, + ), + "text": {"type": "plain_text", "text": "Contact Admin"}, + } + ] + ) + + assert resp.status_code == 200, resp.content + assert resp.content == b"" diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index ad3f68750d41..8df589324d91 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -369,6 +369,22 @@ def assert_configure_auto_reload_button( assert routing.organization_id == organization_id +def assert_contact_admin_button(renderable: SlackRenderable, *, organization_id: int) -> None: + blocks = renderable["blocks"] + assert len(blocks) == 2 + assert isinstance(blocks[1], ActionsBlock) + assert len(blocks[1].elements) == 1 + button = blocks[1].elements[0] + assert isinstance(button, ButtonElement) + assert not isinstance(button, LinkButtonElement) + assert button.text is not None + assert button.text.text == "Contact Admin" + assert button.action_id is not None + routing = decode_action_id(button.action_id) + assert routing.action == SlackAction.SEER_REQUEST_CREDITS.value + assert routing.organization_id == organization_id + + class SeerSlackRendererAutofixOutOfCreditsTest(TestCase): def _render(self, **kwargs: bool) -> SlackRenderable: data = SeerAutofixOutOfCredits(organization_id=self.organization.id, **kwargs) @@ -376,10 +392,10 @@ def _render(self, **kwargs: bool) -> SlackRenderable: data=data, rendered_template=SeerAutofixOutOfCreditsTemplate().render(data) ) - def test_member_gets_no_button(self) -> None: + def test_member_can_contact_admin(self) -> None: renderable = self._render() assert_out_of_credits_message(renderable, AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE) - assert len(renderable["blocks"]) == 1 + assert_contact_admin_button(renderable, organization_id=self.organization.id) def test_billing_member_can_configure_auto_reload(self) -> None: renderable = self._render(has_billing_access=True) @@ -415,10 +431,10 @@ def _render(self, **kwargs: bool) -> SlackRenderable: data=data, rendered_template=SeerAgentOutOfCreditsTemplate().render(data) ) - def test_member_gets_no_button(self) -> None: + def test_member_can_contact_admin(self) -> None: renderable = self._render() assert_out_of_credits_message(renderable, AGENT_OUT_OF_CREDITS_MEMBER_MESSAGE) - assert len(renderable["blocks"]) == 1 + assert_contact_admin_button(renderable, organization_id=self.organization.id) def test_billing_member_can_configure_auto_reload(self) -> None: renderable = self._render(has_billing_access=True) From 82e315687439d85fea951dea7bb8f70f018f3304 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 12 Nov 2026 16:19:47 -0500 Subject: [PATCH 11/11] ref(notifications): Build the out-of-credits button in one place Pick the button's text, action, style and URL per variant in an if/else, then append a single button, instead of appending in two places. Co-Authored-By: Claude Fable 5.2 --- .../platform/slack/renderers/seer.py | 73 +++++++++---------- .../platform/slack/renderers/test_seer.py | 4 +- 2 files changed, 36 insertions(+), 41 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 61f76052d480..56d42477e53a 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -219,48 +219,43 @@ def _render_out_of_credits( message = data.message blocks: list[Block] = [SectionBlock(text=message)] - if not data.has_billing_access: - # TODO(alexsohn): the click does nothing until the handler emails the admins. - blocks.append( - ActionsBlock( - elements=[ - ButtonElement( - text="Contact Admin", - action_id=encode_action_id( - action=SlackAction.SEER_REQUEST_CREDITS.value, - organization_id=data.organization_id, - project_id=None, - ), - ) - ] - ) - ) - return SlackRenderable(blocks=blocks, text=message) - try: - organization = Organization.objects.get_from_cache(id=data.organization_id) - except Organization.DoesNotExist: - organization = None - if organization is not None: - billing_url = organization.absolute_url( - f"/settings/{organization.slug}/billing/overview/" + style: str | None + url: str | None + if data.has_billing_access: + try: + organization = Organization.objects.get_from_cache(id=data.organization_id) + except Organization.DoesNotExist: + return SlackRenderable(blocks=blocks, text=message) + text = "Configure auto-reload" + action = SlackAction.SEER_VIEW_BILLING + style = "primary" + url = link_decorator.decorate_url( + organization.absolute_url(f"/settings/{organization.slug}/billing/overview/") ) - blocks.append( - ActionsBlock( - elements=[ - LinkButtonElement( - text="Configure auto-reload", - style="primary", - url=link_decorator.decorate_url(billing_url), - action_id=encode_action_id( - action=SlackAction.SEER_VIEW_BILLING.value, - organization_id=data.organization_id, - project_id=None, - ), - ) - ] - ) + else: + # TODO(alexsohn): this click does nothing until the handler emails the admins. + text = "Contact Admin" + action = SlackAction.SEER_REQUEST_CREDITS + style = None + url = None + + blocks.append( + ActionsBlock( + elements=[ + ButtonElement( + text=text, + style=style, + url=url, + action_id=encode_action_id( + action=action.value, + organization_id=data.organization_id, + project_id=None, + ), + ) + ] ) + ) return SlackRenderable(blocks=blocks, text=message) @classmethod diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index 8df589324d91..06187512da0b 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -358,7 +358,7 @@ def assert_configure_auto_reload_button( assert isinstance(blocks[1], ActionsBlock) assert len(blocks[1].elements) == 1 button = blocks[1].elements[0] - assert isinstance(button, LinkButtonElement) + assert isinstance(button, ButtonElement) assert button.text is not None assert button.text.text == "Configure auto-reload" assert button.url is not None @@ -376,7 +376,7 @@ def assert_contact_admin_button(renderable: SlackRenderable, *, organization_id: assert len(blocks[1].elements) == 1 button = blocks[1].elements[0] assert isinstance(button, ButtonElement) - assert not isinstance(button, LinkButtonElement) + assert button.url is None assert button.text is not None assert button.text.text == "Contact Admin" assert button.action_id is not None