From 50110cc820d1fb16f0dc45715adeffdcea87c437 Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Sun, 30 Nov 2025 10:52:04 -0500 Subject: [PATCH 1/3] feat(explorer): handle user input run status --- .../organization_seer_explorer_update.py | 19 ++++--------------- src/sentry/seer/explorer/client_models.py | 14 +++++++++++++- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/sentry/seer/endpoints/organization_seer_explorer_update.py b/src/sentry/seer/endpoints/organization_seer_explorer_update.py index 0f8a662503d77d..9050e28f2557f9 100644 --- a/src/sentry/seer/endpoints/organization_seer_explorer_update.py +++ b/src/sentry/seer/endpoints/organization_seer_explorer_update.py @@ -8,13 +8,12 @@ from rest_framework.request import Request from rest_framework.response import Response -from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.models.organization import Organization -from sentry.seer.seer_setup import get_seer_org_acknowledgement +from sentry.seer.explorer.client_utils import has_seer_explorer_access_with_detail from sentry.seer.signed_seer_api import sign_with_seer_secret logger = logging.getLogger(__name__) @@ -38,19 +37,9 @@ def post(self, request: Request, organization: Organization, run_id: int) -> Res """ Send an update event to explorer for a given run. """ - user = request.user - if not features.has( - "organizations:gen-ai-features", organization, actor=user - ) or not features.has("organizations:seer-explorer", organization, actor=user): - return Response({"detail": "Feature flag not enabled"}, status=400) - if organization.get_option("sentry:hide_ai_features"): - return Response( - {"detail": "AI features are disabled for this organization."}, status=403 - ) - if not get_seer_org_acknowledgement(organization): - return Response( - {"detail": "Seer has not been acknowledged by the organization."}, status=403 - ) + has_access, error = has_seer_explorer_access_with_detail(organization, request.user) + if not has_access: + return Response({"detail": error}, status=403) if not request.data: return Response(status=400, data={"error": "Need a body with a payload"}) diff --git a/src/sentry/seer/explorer/client_models.py b/src/sentry/seer/explorer/client_models.py index 43cdfd70dd88f4..b25a1c43bb0e9b 100644 --- a/src/sentry/seer/explorer/client_models.py +++ b/src/sentry/seer/explorer/client_models.py @@ -43,16 +43,28 @@ class Config: extra = "allow" +class PendingUserInput(BaseModel): + """A pending user input request from the agent.""" + + id: str + input_type: str + data: dict[str, Any] + + class Config: + extra = "allow" + + class SeerRunState(BaseModel): """State of a Seer Explorer session.""" run_id: int blocks: list[MemoryBlock] - status: Literal["processing", "completed", "error"] + status: Literal["processing", "completed", "error", "awaiting_user_input"] updated_at: str raw_artifact: dict[str, Any] | None = None artifact: BaseModel | None = None artifact_reason: str | None = None + pending_user_input: PendingUserInput | None = None class Config: extra = "allow" From 10e47f7eddbb6c45d7837fc67a1427727c1993e4 Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Sun, 30 Nov 2025 11:22:25 -0500 Subject: [PATCH 2/3] Fix test --- .../test_organization_seer_explorer_update.py | 55 +++++++++++-------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py b/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py index 84d367c4834d01..7da2d3351e1fdd 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py @@ -15,14 +15,19 @@ def setUp(self) -> None: super().setUp() self.login_as(user=self.user) self.organization = self.create_organization(owner=self.user) + # Explorer requires open team membership + self.organization.flags.allow_joinleave = True + self.organization.save() self.url = f"/api/0/organizations/{self.organization.slug}/seer/explorer-update/123/" - @patch("sentry.seer.endpoints.organization_seer_explorer_update.get_seer_org_acknowledgement") + @patch( + "sentry.seer.endpoints.organization_seer_explorer_update.has_seer_explorer_access_with_detail" + ) @patch("sentry.seer.endpoints.organization_seer_explorer_update.requests.post") def test_explorer_update_successful( - self, mock_post: MagicMock, mock_get_seer_org_acknowledgement: MagicMock + self, mock_post: MagicMock, mock_has_access: MagicMock ) -> None: - mock_get_seer_org_acknowledgement.return_value = True + mock_has_access.return_value = (True, None) mock_post.return_value.status_code = 200 mock_post.return_value.json.return_value = {"run_id": 123} @@ -49,12 +54,14 @@ def test_explorer_update_successful( assert sent_data["run_id"] == "123" assert sent_data["payload"]["type"] == "interrupt" - @patch("sentry.seer.endpoints.organization_seer_explorer_update.get_seer_org_acknowledgement") + @patch( + "sentry.seer.endpoints.organization_seer_explorer_update.has_seer_explorer_access_with_detail" + ) @patch("sentry.seer.endpoints.organization_seer_explorer_update.requests.post") def test_explorer_update_missing_payload( - self, mock_post: MagicMock, mock_get_seer_org_acknowledgement: MagicMock + self, mock_post: MagicMock, mock_has_access: MagicMock ) -> None: - mock_get_seer_org_acknowledgement.return_value = True + mock_has_access.return_value = (True, None) response = self.client.post( self.url, @@ -66,12 +73,11 @@ def test_explorer_update_missing_payload( assert "Need a body with a payload" in str(response.data) mock_post.assert_not_called() - @patch("sentry.seer.endpoints.organization_seer_explorer_update.get_seer_org_acknowledgement") - def test_explorer_update_ai_features_hidden( - self, mock_get_seer_org_acknowledgement: MagicMock - ) -> None: - mock_get_seer_org_acknowledgement.return_value = True - self.organization.update_option("sentry:hide_ai_features", True) + @patch( + "sentry.seer.endpoints.organization_seer_explorer_update.has_seer_explorer_access_with_detail" + ) + def test_explorer_update_ai_features_hidden(self, mock_has_access: MagicMock) -> None: + mock_has_access.return_value = (False, "AI features are disabled for this organization.") response = self.client.post( self.url, @@ -86,11 +92,14 @@ def test_explorer_update_ai_features_hidden( assert response.status_code == status.HTTP_403_FORBIDDEN assert "AI features are disabled" in str(response.data) - @patch("sentry.seer.endpoints.organization_seer_explorer_update.get_seer_org_acknowledgement") - def test_explorer_update_no_seer_acknowledgement( - self, mock_get_seer_org_acknowledgement: MagicMock - ) -> None: - mock_get_seer_org_acknowledgement.return_value = False + @patch( + "sentry.seer.endpoints.organization_seer_explorer_update.has_seer_explorer_access_with_detail" + ) + def test_explorer_update_no_seer_acknowledgement(self, mock_has_access: MagicMock) -> None: + mock_has_access.return_value = ( + False, + "Seer has not been acknowledged by the organization.", + ) response = self.client.post( self.url, @@ -113,11 +122,11 @@ def setUp(self) -> None: self.organization = self.create_organization(owner=self.user) self.url = f"/api/0/organizations/{self.organization.slug}/seer/explorer-update/123/" - @patch("sentry.seer.endpoints.organization_seer_explorer_update.get_seer_org_acknowledgement") - def test_explorer_update_feature_flag_disabled( - self, mock_get_seer_org_acknowledgement: MagicMock - ) -> None: - mock_get_seer_org_acknowledgement.return_value = True + @patch( + "sentry.seer.endpoints.organization_seer_explorer_update.has_seer_explorer_access_with_detail" + ) + def test_explorer_update_feature_flag_disabled(self, mock_has_access: MagicMock) -> None: + mock_has_access.return_value = (False, "Feature flag not enabled") response = self.client.post( self.url, @@ -129,5 +138,5 @@ def test_explorer_update_feature_flag_disabled( format="json", ) - assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.status_code == status.HTTP_403_FORBIDDEN assert "Feature flag not enabled" in str(response.data) From 466c5d23fb6e0401083e70e33b3de849b9936b26 Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Mon, 1 Dec 2025 10:24:38 -0500 Subject: [PATCH 3/3] bug fix --- src/sentry/seer/explorer/client_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py index 1042803b25f2de..9a8240cb3b01d8 100644 --- a/src/sentry/seer/explorer/client_utils.py +++ b/src/sentry/seer/explorer/client_utils.py @@ -147,14 +147,14 @@ def poll_until_done( poll_interval: float, poll_timeout: float, ) -> SeerRunState: - """Poll the run status until completion or timeout.""" + """Poll the run status until completion, error, awaiting_user_input, or timeout.""" start_time = time.time() while True: result = fetch_run_status(run_id, organization) # Check if run is complete - if result.status in ("completed", "error"): + if result.status in ("completed", "error", "awaiting_user_input"): return result # Check timeout