Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 4 additions & 15 deletions src/sentry/seer/endpoints/organization_seer_explorer_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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"})
Expand Down
14 changes: 13 additions & 1 deletion src/sentry/seer/explorer/client_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines 60 to 70

This comment was marked as outdated.

Expand Down
4 changes: 2 additions & 2 deletions src/sentry/seer/explorer/client_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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)
Loading