From 0a7de66d319fefb2b7bd4919d13565644798a2b7 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 5 Nov 2026 15:59:56 -0500 Subject: [PATCH 1/3] feat(seer): Add out-of-credits notification and next-step stopping point Co-Authored-By: Claude Opus 5.5 --- .../platform/slack/renderers/seer.py | 10 ++- .../notifications/platform/templates/seer.py | 38 ++++++++++ src/sentry/notifications/platform/types.py | 2 + .../platform/slack/renderers/test_seer.py | 60 ++++++++++++++++ .../platform/templates/test_seer.py | 69 ++++++++++++++++++- tests/sentry/notifications/test_apps.py | 1 + 6 files changed, 178 insertions(+), 2 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 32e2ae8aad93..d1e7579829c5 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -28,6 +28,7 @@ from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.seer import ( + OUT_OF_CREDITS_MESSAGE, SeerAgentError, SeerAgentResponse, SeerAutofixError, @@ -37,6 +38,7 @@ SeerInvestigationError, SeerInvestigationStarted, SeerInvestigationStatus, + SeerOutOfCredits, ) from sentry.notifications.platform.tracking import NotificationLinkDecorator from sentry.notifications.platform.types import ( @@ -104,6 +106,7 @@ class AutofixStageConfig(TypedDict): NotificationSource.SEER_AUTOFIX_ERROR, NotificationSource.SEER_AUTOFIX_SUCCESS, NotificationSource.SEER_AUTOFIX_UPDATE, + NotificationSource.SEER_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, SeerOutOfCredits): + return SlackRenderable( + blocks=[SectionBlock(text=OUT_OF_CREDITS_MESSAGE)], + text=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_credits_for_next_step and data.next_seer_stopping_point is not None: 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..3f1415778c5f 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: ) +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 SeerOutOfCredits(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_OUT_OF_CREDITS + + +@template_registry.register(NotificationSource.SEER_OUT_OF_CREDITS) +class SeerOutOfCreditsTemplate(NotificationTemplate[SeerOutOfCredits]): + category = NotificationCategory.SEER + example_data = SeerOutOfCredits( + source=NotificationSource.SEER_OUT_OF_CREDITS, + organization_id=1, + started=False, + ) + hide_from_debugger = True + + def render(self, data: SeerOutOfCredits) -> NotificationRenderedTemplate: + return NotificationRenderedTemplate( + subject=OUT_OF_CREDITS_MESSAGE, + body=[ParagraphSection(blocks=[PlainTextBlock(text=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 @@ -100,6 +128,16 @@ def has_next_trigger(self) -> bool: case AutofixStoppingPoint.OPEN_PR: return False + @property + def next_seer_stopping_point(self) -> AutofixStoppingPoint | None: + """The Seer step the update's next-step button starts, or None if it has no such button.""" + # At root cause, a configured handoff replaces the next-step button with a handoff button. + if self.handoff_target and self.current_point == AutofixStoppingPoint.ROOT_CAUSE: + return None + if not self.has_next_trigger: + return None + return _get_next_stopping_point(self.current_point) + @template_registry.register(NotificationSource.SEER_AUTOFIX_UPDATE) class SeerAutofixUpdateTemplate(NotificationTemplate[SeerAutofixUpdate]): diff --git a/src/sentry/notifications/platform/types.py b/src/sentry/notifications/platform/types.py index cbb3b0d3f0fa..b2d503912595 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_OUT_OF_CREDITS = "seer-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_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..5a43cb02323a 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -23,6 +23,7 @@ SeerAgentWriteApprovalSlackRenderer, ) from sentry.notifications.platform.templates.seer import ( + OUT_OF_CREDITS_MESSAGE, SeerAgentError, SeerAgentResponse, SeerAgentWriteApproval, @@ -37,6 +38,8 @@ SeerInvestigationStartedTemplate, SeerInvestigationStatus, SeerInvestigationStatusTemplate, + SeerOutOfCredits, + SeerOutOfCreditsTemplate, ) from sentry.notifications.platform.tracking import NotificationLink, NotificationLinkDecorator from sentry.notifications.platform.types import ( @@ -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 SeerSlackRendererOutOfCreditsTest(TestCase): + def test_render_out_of_credits(self) -> None: + for started in (False, True): + data = SeerOutOfCredits(organization_id=1, started=started) + renderable = render_seer( + data=data, rendered_template=SeerOutOfCreditsTemplate().render(data) + ) + + assert renderable["text"] == 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 == 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..e43ff9c2c4b3 100644 --- a/tests/sentry/notifications/platform/templates/test_seer.py +++ b/tests/sentry/notifications/platform/templates/test_seer.py @@ -5,6 +5,7 @@ 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 ( + OUT_OF_CREDITS_MESSAGE, SeerAgentError, SeerAutofixTrigger, SeerAutofixUpdate, @@ -14,10 +15,12 @@ SeerInvestigationStarted, SeerInvestigationStartedTemplate, SeerInvestigationStatusTemplate, + SeerOutOfCredits, + SeerOutOfCreditsTemplate, _get_next_stopping_point, ) from sentry.notifications.platform.types import ParagraphSection -from sentry.seer.autofix.utils import AutofixStoppingPoint +from sentry.seer.autofix.utils import AutofixStoppingPoint, CodingAgentProviderType from sentry.testutils.cases import TestCase @@ -87,6 +90,57 @@ def test_has_next_trigger_simple(self) -> None: update = self._create_update(AutofixStoppingPoint.OPEN_PR) assert update.has_next_trigger is False + @patch( + "sentry.notifications.platform.templates.seer.organization_service.get_option", + return_value=True, + ) + def test_next_seer_stopping_point(self, _mock_get_option: Mock) -> None: + assert ( + self._create_update(AutofixStoppingPoint.ROOT_CAUSE).next_seer_stopping_point + == AutofixStoppingPoint.SOLUTION + ) + assert ( + self._create_update(AutofixStoppingPoint.SOLUTION).next_seer_stopping_point + == AutofixStoppingPoint.CODE_CHANGES + ) + assert ( + self._create_update(AutofixStoppingPoint.CODE_CHANGES).next_seer_stopping_point + == AutofixStoppingPoint.OPEN_PR + ) + assert self._create_update(AutofixStoppingPoint.OPEN_PR).next_seer_stopping_point is None + + @patch( + "sentry.notifications.platform.templates.seer.organization_service.get_option", + return_value=False, + ) + def test_next_seer_stopping_point_coding_disabled(self, _mock_get_option: Mock) -> None: + assert ( + self._create_update(AutofixStoppingPoint.ROOT_CAUSE).next_seer_stopping_point + == AutofixStoppingPoint.SOLUTION + ) + assert self._create_update(AutofixStoppingPoint.SOLUTION).next_seer_stopping_point is None + assert ( + self._create_update(AutofixStoppingPoint.CODE_CHANGES).next_seer_stopping_point is None + ) + + @patch( + "sentry.notifications.platform.templates.seer.organization_service.get_option", + return_value=True, + ) + def test_next_seer_stopping_point_with_handoff(self, _mock_get_option: Mock) -> None: + handoff = {"handoff_target": CodingAgentProviderType.CURSOR_BACKGROUND_AGENT} + root_cause = self._create_update(AutofixStoppingPoint.ROOT_CAUSE).copy(update=handoff) + assert root_cause.next_seer_stopping_point is None + # The handoff button only replaces the next-step button at root cause. + solution = self._create_update(AutofixStoppingPoint.SOLUTION).copy(update=handoff) + assert solution.next_seer_stopping_point == AutofixStoppingPoint.CODE_CHANGES + + def test_next_seer_stopping_point_ignores_credits(self) -> None: + update = self._create_update(AutofixStoppingPoint.ROOT_CAUSE).copy( + update={"has_credits_for_next_step": False} + ) + assert update.next_seer_stopping_point == AutofixStoppingPoint.SOLUTION + @patch("sentry.notifications.platform.templates.seer.organization_service.get_option") def test_has_next_trigger_coding_disabled(self, mock_get_option: Mock) -> None: mock_get_option.return_value = False @@ -142,6 +196,19 @@ def test_render_custom_title(self) -> None: assert rendered.body[0].blocks[0].text == "Timeout." +class SeerOutOfCreditsTemplateTest(TestCase): + def test_render(self) -> None: + for started in (False, True): + rendered = SeerOutOfCreditsTemplate().render( + SeerOutOfCredits(organization_id=1, started=started) + ) + + assert rendered.subject == OUT_OF_CREDITS_MESSAGE + assert len(rendered.body) == 1 + assert isinstance(rendered.body[0], ParagraphSection) + assert rendered.body[0].blocks[0].text == 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..363622c5b1b7 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_OUT_OF_CREDITS), } From 137dd05b4c312e546d69bdef4065f94ca11e8a2d Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 5 Nov 2026 16:30:16 -0500 Subject: [PATCH 2/3] ref(seer): Name the out-of-credits notification for autofix Co-Authored-By: Claude Opus 5.5 --- .../platform/slack/renderers/seer.py | 12 +++++------ .../notifications/platform/templates/seer.py | 20 +++++++++---------- src/sentry/notifications/platform/types.py | 4 ++-- .../platform/slack/renderers/test_seer.py | 16 +++++++-------- .../platform/templates/test_seer.py | 16 +++++++-------- tests/sentry/notifications/test_apps.py | 2 +- 6 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index d1e7579829c5..9426d740c1ba 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -28,17 +28,17 @@ from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.seer import ( - OUT_OF_CREDITS_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_MESSAGE, SeerAgentError, SeerAgentResponse, SeerAutofixError, + SeerAutofixOutOfCredits, SeerAutofixTrigger, SeerAutofixUpdate, SeerInvestigationConclusion, SeerInvestigationError, SeerInvestigationStarted, SeerInvestigationStatus, - SeerOutOfCredits, ) from sentry.notifications.platform.tracking import NotificationLinkDecorator from sentry.notifications.platform.types import ( @@ -106,7 +106,7 @@ class AutofixStageConfig(TypedDict): NotificationSource.SEER_AUTOFIX_ERROR, NotificationSource.SEER_AUTOFIX_SUCCESS, NotificationSource.SEER_AUTOFIX_UPDATE, - NotificationSource.SEER_OUT_OF_CREDITS, + NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS, NotificationSource.SEER_AGENT_RESPONSE, NotificationSource.SEER_AGENT_ERROR, NotificationSource.SEER_INVESTIGATION_STARTED, @@ -134,10 +134,10 @@ 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, SeerOutOfCredits): + elif isinstance(data, SeerAutofixOutOfCredits): return SlackRenderable( - blocks=[SectionBlock(text=OUT_OF_CREDITS_MESSAGE)], - text=OUT_OF_CREDITS_MESSAGE, + blocks=[SectionBlock(text=AUTOFIX_OUT_OF_CREDITS_MESSAGE)], + text=AUTOFIX_OUT_OF_CREDITS_MESSAGE, ) elif isinstance(data, SeerAgentError): return cls._render_agent_error(data) diff --git a/src/sentry/notifications/platform/templates/seer.py b/src/sentry/notifications/platform/templates/seer.py index 3f1415778c5f..d35022df61b1 100644 --- a/src/sentry/notifications/platform/templates/seer.py +++ b/src/sentry/notifications/platform/templates/seer.py @@ -56,30 +56,30 @@ def render(self, data: SeerAutofixError) -> NotificationRenderedTemplate: ) -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 org does not have enough credits to continue your Autofix run. Set up auto top up to keep your flow uninterrupted." -class SeerOutOfCredits(NotificationData): +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_OUT_OF_CREDITS + source: NotificationSource = NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS -@template_registry.register(NotificationSource.SEER_OUT_OF_CREDITS) -class SeerOutOfCreditsTemplate(NotificationTemplate[SeerOutOfCredits]): +@template_registry.register(NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS) +class SeerAutofixOutOfCreditsTemplate(NotificationTemplate[SeerAutofixOutOfCredits]): category = NotificationCategory.SEER - example_data = SeerOutOfCredits( - source=NotificationSource.SEER_OUT_OF_CREDITS, + example_data = SeerAutofixOutOfCredits( + source=NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS, organization_id=1, started=False, ) hide_from_debugger = True - def render(self, data: SeerOutOfCredits) -> NotificationRenderedTemplate: + def render(self, data: SeerAutofixOutOfCredits) -> NotificationRenderedTemplate: return NotificationRenderedTemplate( - subject=OUT_OF_CREDITS_MESSAGE, - body=[ParagraphSection(blocks=[PlainTextBlock(text=OUT_OF_CREDITS_MESSAGE)])], + subject=AUTOFIX_OUT_OF_CREDITS_MESSAGE, + body=[ParagraphSection(blocks=[PlainTextBlock(text=AUTOFIX_OUT_OF_CREDITS_MESSAGE)])], ) diff --git a/src/sentry/notifications/platform/types.py b/src/sentry/notifications/platform/types.py index b2d503912595..9eebc0778361 100644 --- a/src/sentry/notifications/platform/types.py +++ b/src/sentry/notifications/platform/types.py @@ -69,7 +69,7 @@ class NotificationSource(StrEnum): SEER_AUTOFIX_TRIGGER = "seer-autofix-trigger" SEER_AUTOFIX_FOOTER = "seer-autofix-footer" SEER_AUTOFIX_SUCCESS = "seer-autofix-success" - SEER_OUT_OF_CREDITS = "seer-out-of-credits" + 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" @@ -138,7 +138,7 @@ class NotificationSource(StrEnum): NotificationSource.SEER_AUTOFIX_ERROR, NotificationSource.SEER_AUTOFIX_SUCCESS, NotificationSource.SEER_AUTOFIX_UPDATE, - NotificationSource.SEER_OUT_OF_CREDITS, + 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 5a43cb02323a..520499d95f9f 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -23,11 +23,13 @@ SeerAgentWriteApprovalSlackRenderer, ) from sentry.notifications.platform.templates.seer import ( - OUT_OF_CREDITS_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_MESSAGE, SeerAgentError, SeerAgentResponse, SeerAgentWriteApproval, SeerAutofixCodeChange, + SeerAutofixOutOfCredits, + SeerAutofixOutOfCreditsTemplate, SeerAutofixPullRequest, SeerAutofixUpdate, SeerInvestigationConclusionTemplate, @@ -38,8 +40,6 @@ SeerInvestigationStartedTemplate, SeerInvestigationStatus, SeerInvestigationStatusTemplate, - SeerOutOfCredits, - SeerOutOfCreditsTemplate, ) from sentry.notifications.platform.tracking import NotificationLink, NotificationLinkDecorator from sentry.notifications.platform.types import ( @@ -369,20 +369,20 @@ def test_render_autofix_update_solution_no_next_trigger_when_coding_disabled( assert isinstance(actions_block.elements[0], LinkButtonElement) -class SeerSlackRendererOutOfCreditsTest(TestCase): +class SeerSlackRendererAutofixOutOfCreditsTest(TestCase): def test_render_out_of_credits(self) -> None: for started in (False, True): - data = SeerOutOfCredits(organization_id=1, started=started) + data = SeerAutofixOutOfCredits(organization_id=1, started=started) renderable = render_seer( - data=data, rendered_template=SeerOutOfCreditsTemplate().render(data) + data=data, rendered_template=SeerAutofixOutOfCreditsTemplate().render(data) ) - assert renderable["text"] == OUT_OF_CREDITS_MESSAGE + 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 == OUT_OF_CREDITS_MESSAGE + assert blocks[0].text.text == AUTOFIX_OUT_OF_CREDITS_MESSAGE class SeerSlackRendererAgentErrorTest(TestCase): diff --git a/tests/sentry/notifications/platform/templates/test_seer.py b/tests/sentry/notifications/platform/templates/test_seer.py index e43ff9c2c4b3..91e352ad11fc 100644 --- a/tests/sentry/notifications/platform/templates/test_seer.py +++ b/tests/sentry/notifications/platform/templates/test_seer.py @@ -5,8 +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 ( - OUT_OF_CREDITS_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_MESSAGE, SeerAgentError, + SeerAutofixOutOfCredits, + SeerAutofixOutOfCreditsTemplate, SeerAutofixTrigger, SeerAutofixUpdate, SeerInvestigationConclusionTemplate, @@ -15,8 +17,6 @@ SeerInvestigationStarted, SeerInvestigationStartedTemplate, SeerInvestigationStatusTemplate, - SeerOutOfCredits, - SeerOutOfCreditsTemplate, _get_next_stopping_point, ) from sentry.notifications.platform.types import ParagraphSection @@ -196,17 +196,17 @@ def test_render_custom_title(self) -> None: assert rendered.body[0].blocks[0].text == "Timeout." -class SeerOutOfCreditsTemplateTest(TestCase): +class SeerAutofixOutOfCreditsTemplateTest(TestCase): def test_render(self) -> None: for started in (False, True): - rendered = SeerOutOfCreditsTemplate().render( - SeerOutOfCredits(organization_id=1, started=started) + rendered = SeerAutofixOutOfCreditsTemplate().render( + SeerAutofixOutOfCredits(organization_id=1, started=started) ) - assert rendered.subject == OUT_OF_CREDITS_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 == OUT_OF_CREDITS_MESSAGE + assert rendered.body[0].blocks[0].text == AUTOFIX_OUT_OF_CREDITS_MESSAGE class SeerInvestigationStartedTemplateTest(TestCase): diff --git a/tests/sentry/notifications/test_apps.py b/tests/sentry/notifications/test_apps.py index 363622c5b1b7..36583137f3d2 100644 --- a/tests/sentry/notifications/test_apps.py +++ b/tests/sentry/notifications/test_apps.py @@ -56,5 +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_OUT_OF_CREDITS), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS), } From c0bf325ee33f251ddab0333006c1a859c66ec65f Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 5 Nov 2026 16:38:45 -0500 Subject: [PATCH 3/3] ref(seer): Drop next_seer_stopping_point Co-Authored-By: Claude Opus 5.5 --- .../platform/slack/renderers/seer.py | 2 +- .../notifications/platform/templates/seer.py | 10 ---- .../platform/templates/test_seer.py | 53 +------------------ 3 files changed, 2 insertions(+), 63 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 9426d740c1ba..c8420b8b718a 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -229,7 +229,7 @@ def _render_autofix_update( project_id=data.project_id, ) ) - elif data.has_credits_for_next_step and data.next_seer_stopping_point is not None: + 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 d35022df61b1..7d3fa8f5970e 100644 --- a/src/sentry/notifications/platform/templates/seer.py +++ b/src/sentry/notifications/platform/templates/seer.py @@ -128,16 +128,6 @@ def has_next_trigger(self) -> bool: case AutofixStoppingPoint.OPEN_PR: return False - @property - def next_seer_stopping_point(self) -> AutofixStoppingPoint | None: - """The Seer step the update's next-step button starts, or None if it has no such button.""" - # At root cause, a configured handoff replaces the next-step button with a handoff button. - if self.handoff_target and self.current_point == AutofixStoppingPoint.ROOT_CAUSE: - return None - if not self.has_next_trigger: - return None - return _get_next_stopping_point(self.current_point) - @template_registry.register(NotificationSource.SEER_AUTOFIX_UPDATE) class SeerAutofixUpdateTemplate(NotificationTemplate[SeerAutofixUpdate]): diff --git a/tests/sentry/notifications/platform/templates/test_seer.py b/tests/sentry/notifications/platform/templates/test_seer.py index 91e352ad11fc..9a560bfe2dca 100644 --- a/tests/sentry/notifications/platform/templates/test_seer.py +++ b/tests/sentry/notifications/platform/templates/test_seer.py @@ -20,7 +20,7 @@ _get_next_stopping_point, ) from sentry.notifications.platform.types import ParagraphSection -from sentry.seer.autofix.utils import AutofixStoppingPoint, CodingAgentProviderType +from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.testutils.cases import TestCase @@ -90,57 +90,6 @@ def test_has_next_trigger_simple(self) -> None: update = self._create_update(AutofixStoppingPoint.OPEN_PR) assert update.has_next_trigger is False - @patch( - "sentry.notifications.platform.templates.seer.organization_service.get_option", - return_value=True, - ) - def test_next_seer_stopping_point(self, _mock_get_option: Mock) -> None: - assert ( - self._create_update(AutofixStoppingPoint.ROOT_CAUSE).next_seer_stopping_point - == AutofixStoppingPoint.SOLUTION - ) - assert ( - self._create_update(AutofixStoppingPoint.SOLUTION).next_seer_stopping_point - == AutofixStoppingPoint.CODE_CHANGES - ) - assert ( - self._create_update(AutofixStoppingPoint.CODE_CHANGES).next_seer_stopping_point - == AutofixStoppingPoint.OPEN_PR - ) - assert self._create_update(AutofixStoppingPoint.OPEN_PR).next_seer_stopping_point is None - - @patch( - "sentry.notifications.platform.templates.seer.organization_service.get_option", - return_value=False, - ) - def test_next_seer_stopping_point_coding_disabled(self, _mock_get_option: Mock) -> None: - assert ( - self._create_update(AutofixStoppingPoint.ROOT_CAUSE).next_seer_stopping_point - == AutofixStoppingPoint.SOLUTION - ) - assert self._create_update(AutofixStoppingPoint.SOLUTION).next_seer_stopping_point is None - assert ( - self._create_update(AutofixStoppingPoint.CODE_CHANGES).next_seer_stopping_point is None - ) - - @patch( - "sentry.notifications.platform.templates.seer.organization_service.get_option", - return_value=True, - ) - def test_next_seer_stopping_point_with_handoff(self, _mock_get_option: Mock) -> None: - handoff = {"handoff_target": CodingAgentProviderType.CURSOR_BACKGROUND_AGENT} - root_cause = self._create_update(AutofixStoppingPoint.ROOT_CAUSE).copy(update=handoff) - assert root_cause.next_seer_stopping_point is None - # The handoff button only replaces the next-step button at root cause. - solution = self._create_update(AutofixStoppingPoint.SOLUTION).copy(update=handoff) - assert solution.next_seer_stopping_point == AutofixStoppingPoint.CODE_CHANGES - - def test_next_seer_stopping_point_ignores_credits(self) -> None: - update = self._create_update(AutofixStoppingPoint.ROOT_CAUSE).copy( - update={"has_credits_for_next_step": False} - ) - assert update.next_seer_stopping_point == AutofixStoppingPoint.SOLUTION - @patch("sentry.notifications.platform.templates.seer.organization_service.get_option") def test_has_next_trigger_coding_disabled(self, mock_get_option: Mock) -> None: mock_get_option.return_value = False