diff --git a/src/sentry/seer/agent/client.py b/src/sentry/seer/agent/client.py index 9b664488a569..28b560d2a4e6 100644 --- a/src/sentry/seer/agent/client.py +++ b/src/sentry/seer/agent/client.py @@ -8,12 +8,16 @@ import sentry_sdk from django.contrib.auth.models import AnonymousUser +from django.db import router, transaction from django.utils import timezone as django_timezone +from django.utils.timezone import now from pydantic import BaseModel from rest_framework.request import Request from sentry import features, options from sentry.constants import ENABLE_SEER_CODING_DEFAULT, ObjectStatus +from sentry.hybridcloud.models.outbox import CellOutbox, outbox_context +from sentry.hybridcloud.outbox.category import OutboxCategory, OutboxScope from sentry.models.organization import Organization from sentry.models.project import Project from sentry.seer.agent.client_models import AgentRun, AgentRunWithPrs, SeerRunState @@ -36,6 +40,7 @@ extract_hook_definition, ) from sentry.seer.models import SeerApiError, SeerPermissionError, SeerRepoDefinition +from sentry.seer.models.run import SeerRun, SeerRunMirrorStatus, SeerRunType from sentry.seer.seer_setup import has_seer_access_with_detail from sentry.seer.signed_seer_api import SeerViewerContext from sentry.tasks.seer.context_engine_index import build_service_map, index_org_project_knowledge @@ -254,6 +259,7 @@ def start_run( request: Request | None = None, override_ce_enable: bool = True, ui_tools: str | None = None, + run_type: SeerRunType | None = None, ) -> int: """ Start a new Seer Agent session. @@ -345,6 +351,34 @@ def start_run( ): chat_body["is_context_engine_enabled"] = override_ce_enable + if run_type and features.has("organizations:seer-run-mirror", self.organization): + user_id = ( + self.user.id + if self.user and hasattr(self.user, "id") and self.user.id is not None + else None + ) + with outbox_context(transaction.atomic(using=router.db_for_write(SeerRun)), flush=True): + run = SeerRun.objects.create( + organization=self.organization, + user_id=user_id, + type=run_type, + last_triggered_at=now(), + ) + CellOutbox( + shard_scope=OutboxScope.ORGANIZATION_SCOPE, + shard_identifier=self.organization.id, + category=OutboxCategory.SEER_RUN_CREATE, + object_identifier=run.id, + payload={ + "body": dict(chat_body), + "viewer_context": dict(self.viewer_context), + }, + ).save() + run.refresh_from_db() + if run.mirror_status != SeerRunMirrorStatus.LIVE or run.seer_run_state_id is None: + raise SeerApiError("Seer run mirror failed to materialize", 500) + return run.seer_run_state_id + response = make_agent_chat_request(chat_body, viewer_context=self.viewer_context) if response.status >= 400: diff --git a/src/sentry/seer/endpoints/organization_seer_agent_chat.py b/src/sentry/seer/endpoints/organization_seer_agent_chat.py index d522e1fc5230..d363532f522e 100644 --- a/src/sentry/seer/endpoints/organization_seer_agent_chat.py +++ b/src/sentry/seer/endpoints/organization_seer_agent_chat.py @@ -21,6 +21,7 @@ snapshot_to_markdown, ) from sentry.seer.models import SeerApiError, SeerPermissionError +from sentry.seer.models.run import SeerRunType from sentry.seer.seer_setup import has_seer_access_with_detail from sentry.types.ratelimit import RateLimit, RateLimitCategory from sentry.utils import json @@ -274,6 +275,7 @@ def post( ui_tools=ui_tools, override_ce_enable=override_ce_enable, request=request, + run_type=SeerRunType.EXPLORER, ) return Response({"run_id": result_run_id}) diff --git a/tests/sentry/seer/endpoints/test_organization_seer_agent_chat.py b/tests/sentry/seer/endpoints/test_organization_seer_agent_chat.py index 79d68b2b2c95..3ff2de377a7f 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_agent_chat.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_agent_chat.py @@ -1,9 +1,10 @@ from typing import Any -from unittest.mock import ANY, MagicMock, patch +from unittest.mock import ANY, MagicMock, Mock, patch import pytest from sentry.seer.endpoints.organization_seer_agent_chat import SeerAgentChatSerializer +from sentry.seer.models.run import SeerRunType from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.features import with_feature from sentry.utils import json @@ -126,6 +127,7 @@ def test_post_new_conversation_calls_client(self, mock_client_class: MagicMock): ui_tools=None, override_ce_enable=True, request=ANY, + run_type=SeerRunType.EXPLORER, ) @patch("sentry.seer.endpoints.organization_seer_agent_chat.SeerAgentClient") @@ -330,6 +332,44 @@ def test_post_ascii_on_page_context_passed_through(self, mock_client_class: Magi call_kwargs = mock_client.start_run.call_args[1] assert call_kwargs["on_page_context"] == ascii_screenshot + @patch("sentry.receivers.outbox.cell.make_agent_chat_request") + @patch("sentry.seer.agent.client.has_seer_access_with_detail") + @patch("sentry.seer.agent.client.collect_user_org_context") + def test_outbox_path_creates_run_and_returns_run_id( + self, mock_collect_context: MagicMock, mock_access: MagicMock, mock_request: Mock + ) -> None: + mock_access.return_value = (True, None) + mock_collect_context.return_value = {} + mock_request.return_value = Mock(status=200, json=Mock(return_value={"run_id": 99})) + + from sentry.seer.agent.client import SeerAgentClient + + client = SeerAgentClient( + self.organization, self.user, is_interactive=True, reasoning_effort="medium" + ) + + with self.feature("organizations:seer-run-mirror"): + run_id = client.start_run( + prompt="What happened?", + run_type=SeerRunType.EXPLORER, + ) + + assert run_id == 99 + + from sentry.seer.models.run import SeerRun, SeerRunMirrorStatus + + run = SeerRun.objects.get(organization_id=self.organization.id) + assert run.type == SeerRunType.EXPLORER + assert run.mirror_status == SeerRunMirrorStatus.LIVE + assert run.seer_run_state_id == 99 + assert run.user_id == self.user.id + + sent_body = mock_request.call_args[0][0] + assert sent_body["query"] == "What happened?" + assert sent_body["organization_id"] == self.organization.id + assert "external_idempotency_key" in sent_body + assert sent_body["external_idempotency_key"] == str(run.uuid) + @with_feature("organizations:seer-explorer") @with_feature("organizations:gen-ai-features")