diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 32e2ae8aad93..c8420b8b718a 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, SeerAutofixTrigger, SeerAutofixUpdate, SeerInvestigationConclusion, @@ -104,6 +106,7 @@ class AutofixStageConfig(TypedDict): NotificationSource.SEER_AUTOFIX_ERROR, 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, @@ -131,6 +134,11 @@ def render[DataT: NotificationData]( return cls._render_autofix_error(data) 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): @@ -221,7 +229,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 1ea1500b9db7..7d3fa8f5970e 100644 --- a/src/sentry/notifications/platform/templates/seer.py +++ b/src/sentry/notifications/platform/templates/seer.py @@ -56,6 +56,33 @@ def render(self, data: SeerAutofixError) -> 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 @@ -82,6 +109,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 cbb3b0d3f0fa..9eebc0778361 100644 --- a/src/sentry/notifications/platform/types.py +++ b/src/sentry/notifications/platform/types.py @@ -69,6 +69,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" @@ -137,6 +138,7 @@ class NotificationSource(StrEnum): NotificationSource.SEER_AUTOFIX_ERROR, 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 cefcaa9820b0..520499d95f9f 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -23,10 +23,13 @@ SeerAgentWriteApprovalSlackRenderer, ) from sentry.notifications.platform.templates.seer import ( + AUTOFIX_OUT_OF_CREDITS_MESSAGE, SeerAgentError, SeerAgentResponse, SeerAgentWriteApproval, SeerAutofixCodeChange, + SeerAutofixOutOfCredits, + SeerAutofixOutOfCreditsTemplate, SeerAutofixPullRequest, SeerAutofixUpdate, SeerInvestigationConclusionTemplate, @@ -230,6 +233,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, @@ -291,6 +320,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, @@ -325,6 +369,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 432b9db2b9f4..36583137f3d2 100644 --- a/tests/sentry/notifications/test_apps.py +++ b/tests/sentry/notifications/test_apps.py @@ -56,4 +56,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), }