diff --git a/src/sentry/seer/agent/client.py b/src/sentry/seer/agent/client.py index 72df68476391..55f64de64cc3 100644 --- a/src/sentry/seer/agent/client.py +++ b/src/sentry/seer/agent/client.py @@ -562,6 +562,7 @@ def _create_agent_run(run: SeerRun) -> None: if on_run_created is not None: on_run_created(run) + agent_run_options = self._build_agent_run_options() return enqueue_seer_run( organization=self.organization, run_type=SeerRunType.FEATURE_RUN, @@ -569,7 +570,13 @@ def _create_agent_run(run: SeerRun) -> None: body=SeerFeatureRunRequest( feature_id=feature_id, payload=payload, - agent_run_options=self._build_agent_run_options(), + agent_run_options=agent_run_options, + # Seer's feature runner reads this top-level flag; mirror what + # _build_agent_run_options resolved so the feature honors the org's + # context-engine rollout / FE-override. + is_context_engine_enabled=bool( + agent_run_options.get("is_context_engine_enabled", False) + ), ), viewer_context=self.viewer_context, user_id=user_id, diff --git a/src/sentry/seer/agent/client_utils.py b/src/sentry/seer/agent/client_utils.py index 8b613505e446..66a3f3005dd5 100644 --- a/src/sentry/seer/agent/client_utils.py +++ b/src/sentry/seer/agent/client_utils.py @@ -123,6 +123,9 @@ class SeerFeatureRunRequest(TypedDict): feature_id: str payload: dict[str, Any] agent_run_options: NotRequired[dict[str, Any]] + # Top-level context-engine flag Seer's feature runner reads + # (FeatureRunRequest.is_context_engine_enabled) to gate context-engine tools. + is_context_engine_enabled: NotRequired[bool] class SeerFeatureRunWireRequest(SeerFeatureRunRequest): diff --git a/src/sentry/seer/agent/feature_delivery.py b/src/sentry/seer/agent/feature_delivery.py index 27f270a802ee..c38b9d599cb6 100644 --- a/src/sentry/seer/agent/feature_delivery.py +++ b/src/sentry/seer/agent/feature_delivery.py @@ -1,10 +1,9 @@ -"""Registry for Seer feature result delivery handlers.""" - from __future__ import annotations from typing import Any, Protocol from sentry.seer.agent.types import FeatureRunStatus +from sentry.seer.autofix_rca.delivery import deliver_autofix_rca_result from sentry.seer.night_shift.delivery import deliver_night_shift_result __all__ = ["DELIVERY_HANDLERS", "FeatureDeliveryFn", "FeatureRunStatus"] @@ -23,4 +22,5 @@ def __call__( DELIVERY_HANDLERS: dict[str, FeatureDeliveryFn] = { "night_shift": deliver_night_shift_result, + "autofix_rca": deliver_autofix_rca_result, } diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index ef5fafb6c6fc..b43dfb5b553a 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -224,6 +224,131 @@ def get_step_webhook_action_type(step: AutofixStep, is_completed: bool) -> SeerA return step_to_action_type[step][is_completed] +def broadcast_step_webhook( + organization: Organization, + step: AutofixStep, + is_completed: bool, + payload: dict[str, Any], +) -> None: + webhook_action_type = get_step_webhook_action_type(step, is_completed=is_completed) + event_name = webhook_action_type.value + + event_type = f"seer.{event_name}" + try: + sentry_app_event_type = SentryAppEventType(event_type) + if SeerAutofixOperator.has_access(organization=organization): + process_autofix_updates.apply_async( + kwargs={ + "event_type": sentry_app_event_type, + "event_payload": payload, + "organization_id": organization.id, + } + ) + except ValueError: + logger.exception( + "autofix.trigger.webhook_invalid_event_type", + extra={"event_type": event_type}, + ) + + try: + broadcast_webhooks_for_organization.delay( + resource_name="seer", + event_name=event_name, + organization_id=organization.id, + payload=payload, + ) + except Exception: + logger.exception( + "autofix.trigger.webhook_failed", + extra={"organization_id": organization.id, "webhook_event": event_name}, + ) + + +def handle_step_started_events( + group: Group, + step: AutofixStep, + run_id: int, + sentry_run_uuid: str | None, + referrer: AutofixReferrer, + iteration_index: int | None = None, +) -> None: + config = STEP_CONFIGS[step] + if config.started_event is not None: + analytics.record( + config.started_event( + organization_id=group.organization.id, + project_id=group.project_id, + group_id=group.id, + referrer=referrer.value, + run_id=run_id, + iteration_index=iteration_index, + ) + ) + + payload: dict[str, Any] = { + "run_id": run_id, + "sentry_run_id": sentry_run_uuid, + "group_id": group.id, + } + if iteration_index is not None: + payload["iteration_index"] = iteration_index + + broadcast_step_webhook(group.organization, step, is_completed=False, payload=payload) + + metrics.incr( + "autofix.explorer.trigger", + tags={ + "step": step.value, + "referrer": referrer.value, + "iteration_index": iteration_index, + }, + ) + + +def handle_step_completed_events( + group: Group, + step: AutofixStep, + run_id: int, + sentry_run_uuid: str | None, + referrer: AutofixReferrer, + artifact_data: dict[str, Any] | None = None, + iteration_index: int | None = None, +) -> None: + payload: dict[str, Any] = { + "run_id": run_id, + "sentry_run_id": sentry_run_uuid, + "group_id": group.id, + } + if artifact_data is not None: + payload[step.value] = artifact_data + if iteration_index is not None: + payload["iteration_index"] = iteration_index + + broadcast_step_webhook(group.organization, step, is_completed=True, payload=payload) + + completed_event_cls = STEP_CONFIGS[step].completed_event + if completed_event_cls is not None: + analytics.record( + completed_event_cls( + organization_id=group.organization.id, + project_id=group.project_id, + group_id=group.id, + referrer=referrer.value, + run_id=run_id, + iteration_index=iteration_index, + ) + ) + + metrics.incr( + "autofix.explorer.complete", + tags={ + "step": step.value, + "referrer": referrer.value, + "iteration_index": iteration_index, + }, + ) + + @dataclass(frozen=True) class Iteration: index: int @@ -495,76 +620,13 @@ def trigger_autofix_agent( seer_run_state_id=run_id, ).first() - # Emit the started event after run_id is resolved so it can be joined to - # downstream completed/PR events. - if config.started_event is not None: - analytics.record( - config.started_event( - organization_id=group.organization.id, - project_id=group.project_id, - group_id=group.id, - referrer=referrer.value, - run_id=run_id, - iteration_index=iteration_index, - ) - ) - - payload: dict[str, Any] = { - "run_id": run_id, - "sentry_run_id": str(run.uuid) if run is not None else None, - "group_id": group.id, - } - if iteration_index is not None: - payload["iteration_index"] = iteration_index - - webhook_action_type = get_step_webhook_action_type(step, is_completed=False) - event_name = webhook_action_type.value - - event_type = f"seer.{event_name}" - try: - sentry_app_event_type = SentryAppEventType(event_type) - if SeerAutofixOperator.has_access(organization=group.organization): - process_autofix_updates.apply_async( - kwargs={ - "event_type": sentry_app_event_type, - "event_payload": payload, - "organization_id": group.organization.id, - } - ) - except ValueError: - logger.exception( - "autofix.trigger.webhook_invalid_event_type", - extra={"event_type": event_type}, - ) - - # Send "started" webhook after we have the run_id - try: - broadcast_webhooks_for_organization.delay( - resource_name="seer", - event_name=event_name, - organization_id=group.organization.id, - payload=payload, - ) - except Exception: - logger.exception( - "autofix.trigger.webhook_failed", - extra={ - "organization_id": group.organization.id, - "webhook_event": event_name, - "step": step.value, - "run_id": run_id, - "group_id": group.id, - "iteration_index": iteration_index, - }, - ) - - metrics.incr( - "autofix.explorer.trigger", - tags={ - "step": step.value, - "referrer": referrer.value, - "iteration_index": iteration_index, - }, + handle_step_started_events( + group, + step, + run_id, + str(run.uuid) if run is not None else None, + referrer, + iteration_index, ) return run_id diff --git a/src/sentry/seer/autofix_rca/__init__.py b/src/sentry/seer/autofix_rca/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/sentry/seer/autofix_rca/delivery.py b/src/sentry/seer/autofix_rca/delivery.py new file mode 100644 index 000000000000..77df3215c032 --- /dev/null +++ b/src/sentry/seer/autofix_rca/delivery.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import logging +from collections.abc import Mapping +from typing import Any + +import sentry_sdk +from django.db import router, transaction +from django.utils import timezone + +from sentry.models.group import Group +from sentry.seer.agent.types import FeatureRunStatus +from sentry.seer.autofix.autofix_agent import AutofixStep, handle_step_completed_events +from sentry.seer.autofix.constants import AutofixReferrer +from sentry.seer.autofix_rca.models import FEATURE_ID, AutofixRCAResult +from sentry.seer.models.run import SeerAgentRun, SeerRun + +logger = logging.getLogger(__name__) + + +def _resolve_referrer(extras: Mapping[str, Any] | None) -> AutofixReferrer: + raw = (extras or {}).get("referrer") + if not isinstance(raw, str): + return AutofixReferrer.UNKNOWN + try: + return AutofixReferrer(raw) + except ValueError: + return AutofixReferrer.UNKNOWN + + +def deliver_autofix_rca_result( + organization_id: int, + run_uuid: str, + status: FeatureRunStatus, + result: dict[str, Any] | None, + error: str | None, +) -> None: + logger.info( + "autofix_rca.delivery.received", + extra={"organization_id": organization_id, "run_uuid": run_uuid, "status": status}, + ) + agent_run = ( + SeerAgentRun.objects.filter( + run__uuid=run_uuid, + run__organization_id=organization_id, + source=FEATURE_ID, + ) + .select_related("run") + .first() + ) + if agent_run is None: + logger.warning( + "autofix_rca.delivery.missing_run", + extra={"organization_id": organization_id, "run_uuid": run_uuid}, + ) + return + + run_state_id = agent_run.run.seer_run_state_id + log_extra: dict[str, object] = { + "organization_id": organization_id, + "run_uuid": run_uuid, + "seer_run_id": run_state_id, + "group_id": agent_run.group_id, + } + + if status == "error" or result is None: + sentry_sdk.metrics.count( + "autofix_rca.delivery_error", + 1, + attributes={"error_type": "delivery_error" if status == "error" else "no_result"}, + ) + agent_run.update( + extras={**(agent_run.extras or {}), "status": "error", "error_message": error} + ) + logger.warning("autofix_rca.delivery.no_result", extra={**log_extra, "status": status}) + return + + try: + rca_result = AutofixRCAResult.parse_obj(result) + except Exception: + sentry_sdk.metrics.count( + "autofix_rca.delivery_error", 1, attributes={"error_type": "invalid_result"} + ) + logger.exception("autofix_rca.delivery.invalid_result", extra=log_extra) + return + + # Persist the full result for offline evaluation of Seer RCA quality. + agent_run.update( + extras={**(agent_run.extras or {}), "status": "completed", "result": rca_result.dict()} + ) + + decision = rca_result.introspection_decision + action = decision.action.value if decision is not None else "none" + + if agent_run.group_id is None or run_state_id is None: + logger.warning("autofix_rca.delivery.cannot_surface", extra=log_extra) + return + + group = ( + Group.objects.filter(id=agent_run.group_id, project__organization_id=organization_id) + .select_related("project", "project__organization") + .first() + ) + if group is None: + logger.warning("autofix_rca.delivery.group_not_found", extra=log_extra) + return + + # Hook back into the existing flow: mark the group triggered and emit the + # completed webhook/analytics the on-completion hook would for a root cause. + now = timezone.now() + with transaction.atomic(using=router.db_for_write(Group)): + group.update(seer_explorer_autofix_last_triggered=now) + SeerRun.objects.filter( + organization_id=organization_id, seer_run_state_id=run_state_id + ).update(last_triggered_at=now) + + handle_step_completed_events( + group, + AutofixStep.ROOT_CAUSE, + run_state_id, + str(agent_run.run.uuid), + _resolve_referrer(agent_run.extras), + artifact_data=rca_result.artifact, + ) + + sentry_sdk.metrics.count( + "autofix_rca.delivery_completed", 1, attributes={"introspection_action": action} + ) + logger.info( + "autofix_rca.delivery.completed", + extra={ + **log_extra, + "introspection_action": action, + "has_artifact": rca_result.artifact is not None, + }, + ) diff --git a/src/sentry/seer/autofix_rca/dispatch.py b/src/sentry/seer/autofix_rca/dispatch.py new file mode 100644 index 000000000000..f06dbd633c1a --- /dev/null +++ b/src/sentry/seer/autofix_rca/dispatch.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Literal + +from sentry import quotas +from sentry.constants import DataCategory +from sentry.seer.agent.client import SeerAgentClient +from sentry.seer.autofix_rca.models import FEATURE_ID, AutofixRCAPayload, AutofixRCATweaks +from sentry.utils import metrics + +if TYPE_CHECKING: + from sentry.models.group import Group + from sentry.seer.autofix.constants import AutofixReferrer + from sentry.seer.models.run import SeerRun + +logger = logging.getLogger(__name__) + + +def trigger_autofix_rca_feature( + group: Group, + *, + referrer: AutofixReferrer, + user_context: str | None = None, + intelligence_level: Literal["low", "medium", "high"] = "medium", + reasoning_effort: Literal["low", "medium", "high"] | None = "medium", +) -> SeerRun: + payload = AutofixRCAPayload( + group_id=group.id, + short_id=group.qualified_short_id or str(group.id), + title=group.title or "Unknown error", + culprit=group.culprit or "unknown", + tweaks=AutofixRCATweaks( + intelligence_level=intelligence_level, + reasoning_effort=reasoning_effort, + user_context=user_context, + ), + ) + + # No category_key/value: for feature runs the authoritative category is set + # on the Seer side (the autofix_rca feature tags its explorer run as + # category_key="autofix" so Sentry's autofix drawer finds it). The client-side + # category is unused by start_feature_run. + client = SeerAgentClient( + organization=group.organization, + project=group.project, + group=group, + ) + + run = client.start_feature_run( + feature_id=FEATURE_ID, + payload=payload.dict(), + title=f"Autofix RCA — {payload.short_id}", + # flush=True: dispatch inline so seer_run_state_id is populated before we return + flush=True, + extras={"group_id": group.id, "referrer": referrer.value}, + ) + + # Match trigger_autofix_agent: a new run consumes Seer autofix budget. + quotas.backend.record_seer_run( + group.organization.id, group.project.id, DataCategory.SEER_AUTOFIX + ) + + metrics.incr("autofix_rca.feature.trigger", tags={"referrer": referrer.value}) + + logger.info( + "autofix_rca.dispatch.started", + extra={ + "group_id": group.id, + "organization_id": group.organization.id, + "run_id": run.seer_run_state_id, + "referrer": referrer.value, + }, + ) + + return run diff --git a/src/sentry/seer/autofix_rca/models.py b/src/sentry/seer/autofix_rca/models.py new file mode 100644 index 000000000000..6cba6a3d8fc4 --- /dev/null +++ b/src/sentry/seer/autofix_rca/models.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from enum import StrEnum +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +# Keep models in sync with src/seer/automation/features/autofix_rca/models.py in Seer + +FEATURE_ID = "autofix_rca" + + +class AutofixRCATweaks(BaseModel): + model_config = ConfigDict(extra="ignore") + + intelligence_level: Literal["low", "medium", "high"] = "medium" + reasoning_effort: Literal["low", "medium", "high"] | None = "medium" + # Free-form context a user attached to the issue + user_context: str | None = None + + +class AutofixRCAPayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + group_id: int + short_id: str + title: str + culprit: str + tweaks: AutofixRCATweaks = Field(default_factory=AutofixRCATweaks) + + +class IntrospectionAction(StrEnum): + CONTINUE = "continue" + NEEDS_MORE_CONTEXT = "needs_more_context" + REDO = "redo" + NOT_ACTIONABLE = "not_actionable" + + +class IntrospectionDecision(BaseModel): + model_config = ConfigDict(extra="ignore") + + action: IntrospectionAction + reason: str = "" + + +class AutofixRCAResult(BaseModel): + model_config = ConfigDict(extra="ignore") + + artifact: dict[str, Any] | None = None + introspection_decision: IntrospectionDecision | None = None diff --git a/tests/sentry/seer/agent/test_agent_client.py b/tests/sentry/seer/agent/test_agent_client.py index 4a785cb250c6..b70f5fcece91 100644 --- a/tests/sentry/seer/agent/test_agent_client.py +++ b/tests/sentry/seer/agent/test_agent_client.py @@ -1479,6 +1479,8 @@ def test_inherits_context_engine_from_org(self, mock_request, _mock_access) -> N assert outbox is not None and outbox.payload is not None body = outbox.payload["body"] assert body["agent_run_options"]["is_context_engine_enabled"] is True + # Surfaced top-level for Seer's feature runner to read. + assert body["is_context_engine_enabled"] is True @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.receivers.outbox.cell.make_feature_run_request") @@ -1506,6 +1508,8 @@ def test_agent_run_options_empty_without_org_flags(self, mock_request, _mock_acc assert outbox is not None and outbox.payload is not None body = outbox.payload["body"] assert body["agent_run_options"] == {} + # Off by default when no context-engine rollout/override applies. + assert body["is_context_engine_enabled"] is False @with_feature("organizations:seer-infra-telemetry") diff --git a/tests/sentry/seer/autofix_rca/__init__.py b/tests/sentry/seer/autofix_rca/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/sentry/seer/autofix_rca/test_delivery.py b/tests/sentry/seer/autofix_rca/test_delivery.py new file mode 100644 index 000000000000..388f6ac3f739 --- /dev/null +++ b/tests/sentry/seer/autofix_rca/test_delivery.py @@ -0,0 +1,136 @@ +from unittest.mock import patch + +from sentry.seer.autofix_rca.delivery import deliver_autofix_rca_result +from sentry.seer.models.run import SeerAgentRun +from sentry.sentry_apps.utils.webhooks import SeerActionType +from sentry.testutils.cases import TestCase +from sentry.testutils.pytest.fixtures import django_db_all + +VALID_RESULT = { + "artifact": { + "one_line_description": "null deref in handler", + "five_whys": ["a", "b", "c"], + "reproduction_steps": ["do x"], + "relevant_repo": "owner/repo", + }, + "introspection_decision": {"action": "continue", "reason": "matches the stacktrace"}, +} + + +@django_db_all +class TestDeliverAutofixRCAResult(TestCase): + def setUp(self) -> None: + super().setUp() + self.group = self.create_group(project=self.project) + + def _create_agent_run(self, seer_run_state_id: int | None = 123) -> SeerAgentRun: + seer_run = self.create_seer_run( + organization=self.organization, + type="feature_run", + seer_run_state_id=seer_run_state_id, + ) + return self.create_seer_agent_run( + run=seer_run, + source="autofix_rca", + group=self.group, + project=self.project, + ) + + def test_missing_run_logs_warning(self) -> None: + with patch("sentry.seer.autofix_rca.delivery.logger") as mock_logger: + deliver_autofix_rca_result( + organization_id=self.organization.id, + run_uuid="00000000-0000-0000-0000-000000000000", + status="completed", + result=VALID_RESULT, + error=None, + ) + + mock_logger.warning.assert_called_once() + assert "autofix_rca.delivery.missing_run" in mock_logger.warning.call_args.args[0] + + @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay") + def test_completed_result_hooks_back_into_flow(self, mock_broadcast) -> None: + agent_run = self._create_agent_run(seer_run_state_id=123) + + deliver_autofix_rca_result( + organization_id=self.organization.id, + run_uuid=str(agent_run.run.uuid), + status="completed", + result=VALID_RESULT, + error=None, + ) + + # Persisted for evaluation. + agent_run.refresh_from_db() + assert agent_run.extras["status"] == "completed" + stored = agent_run.extras["result"] + assert stored["introspection_decision"]["action"] == "continue" + assert stored["artifact"]["one_line_description"] == "null deref in handler" + + # Group marked triggered. + self.group.refresh_from_db() + assert self.group.seer_explorer_autofix_last_triggered is not None + + # ROOT_CAUSE_COMPLETED webhook fired with the root cause payload. + mock_broadcast.assert_called_once() + kwargs = mock_broadcast.call_args.kwargs + assert kwargs["event_name"] == SeerActionType.ROOT_CAUSE_COMPLETED.value + assert kwargs["payload"]["run_id"] == 123 + assert kwargs["payload"]["root_cause"]["one_line_description"] == "null deref in handler" + + def test_error_status_recorded(self) -> None: + agent_run = self._create_agent_run() + + with patch("sentry.seer.autofix_rca.delivery.logger") as mock_logger: + deliver_autofix_rca_result( + organization_id=self.organization.id, + run_uuid=str(agent_run.run.uuid), + status="error", + result=None, + error="seer exploded", + ) + + mock_logger.warning.assert_called() + assert "autofix_rca.delivery.no_result" in mock_logger.warning.call_args.args[0] + + agent_run.refresh_from_db() + assert agent_run.extras["status"] == "error" + assert agent_run.extras["error_message"] == "seer exploded" + assert "result" not in agent_run.extras + + def test_invalid_result_logs_exception(self) -> None: + agent_run = self._create_agent_run() + + with patch("sentry.seer.autofix_rca.delivery.logger") as mock_logger: + deliver_autofix_rca_result( + organization_id=self.organization.id, + run_uuid=str(agent_run.run.uuid), + status="completed", + result={"introspection_decision": {"action": "not-a-real-action"}}, + error=None, + ) + + mock_logger.exception.assert_called_once() + assert "autofix_rca.delivery.invalid_result" in mock_logger.exception.call_args.args[0] + + agent_run.refresh_from_db() + assert "status" not in agent_run.extras + + def test_night_shift_run_is_not_matched(self) -> None: + """Only autofix_rca-sourced runs are handled; a night_shift run with the + same uuid is ignored (dispatched by feature_id upstream, but be safe).""" + seer_run = self.create_seer_run(organization=self.organization, type="feature_run") + self.create_seer_agent_run(run=seer_run, source="night_shift", group=self.group) + + with patch("sentry.seer.autofix_rca.delivery.logger") as mock_logger: + deliver_autofix_rca_result( + organization_id=self.organization.id, + run_uuid=str(seer_run.uuid), + status="completed", + result=VALID_RESULT, + error=None, + ) + + mock_logger.warning.assert_called_once() + assert "autofix_rca.delivery.missing_run" in mock_logger.warning.call_args.args[0] diff --git a/tests/sentry/seer/autofix_rca/test_dispatch.py b/tests/sentry/seer/autofix_rca/test_dispatch.py new file mode 100644 index 000000000000..5c68e5dd575d --- /dev/null +++ b/tests/sentry/seer/autofix_rca/test_dispatch.py @@ -0,0 +1,52 @@ +from unittest.mock import patch + +from sentry.seer.autofix.constants import AutofixReferrer +from sentry.seer.autofix_rca.dispatch import trigger_autofix_rca_feature +from sentry.testutils.cases import TestCase +from sentry.testutils.pytest.fixtures import django_db_all + + +@django_db_all +class TestTriggerAutofixRCAFeature(TestCase): + def setUp(self) -> None: + super().setUp() + self.group = self.create_group(project=self.project) + + def test_dispatches_feature_run(self) -> None: + fake_run = self.create_seer_run(organization=self.organization, type="feature_run") + + with ( + patch("sentry.seer.autofix_rca.dispatch.SeerAgentClient") as MockClient, + patch("sentry.seer.autofix_rca.dispatch.quotas") as mock_quotas, + ): + mock_quotas.backend.check_seer_quota.return_value = True + client = MockClient.return_value + client.start_feature_run.return_value = fake_run + + run = trigger_autofix_rca_feature( + self.group, + referrer=AutofixReferrer.NIGHT_SHIFT, + user_context="an upstream triage summary", + ) + + assert run is fake_run + + # Client scoped to the issue's org/project/group. + client_kwargs = MockClient.call_args.kwargs + assert client_kwargs["organization"] == self.group.organization + assert client_kwargs["project"] == self.group.project + assert client_kwargs["group"] == self.group + + # Feature run dispatched with the RCA payload. + run_kwargs = client.start_feature_run.call_args.kwargs + assert run_kwargs["feature_id"] == "autofix_rca" + assert run_kwargs["flush"] is True + payload = run_kwargs["payload"] + assert payload["group_id"] == self.group.id + assert payload["short_id"] == (self.group.qualified_short_id or str(self.group.id)) + assert payload["title"] == self.group.title + assert payload["tweaks"]["user_context"] == "an upstream triage summary" + assert run_kwargs["extras"]["group_id"] == self.group.id + + # A new run consumes Seer autofix budget. + mock_quotas.backend.record_seer_run.assert_called_once()