From 45bc837d2f212bc1ffb9ca22406cf355036eb6d4 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Fri, 5 Jun 2026 17:35:51 -0400 Subject: [PATCH] ref(seer): Drop night shift in-process triage path Night shift now always dispatches triage to Seer's feature-run endpoint, which pushes verdicts back via deliver_feature_result. The in-process triage path was gated behind seer.night_shift.use_feature_delivery during the cutover and is no longer needed once feature delivery is the default. Remove the option, the _run_triage_in_process branch, and the in-process-only modules (agentic_triage, triage_tools, event_formatter, issue_formatter) plus their tests. The autofix-from-verdicts behavior is covered by test_delivery.py. Co-Authored-By: Claude --- src/sentry/options/defaults.py | 6 - .../tasks/seer/night_shift/agentic_triage.py | 377 ------------ src/sentry/tasks/seer/night_shift/cron.py | 90 +-- .../tasks/seer/night_shift/event_formatter.py | 542 ------------------ .../tasks/seer/night_shift/issue_formatter.py | 189 ------ .../tasks/seer/night_shift/triage_tools.py | 223 ------- .../seer/night_shift/test_event_formatter.py | 206 ------- .../seer/night_shift/test_issue_formatter.py | 116 ---- tests/sentry/tasks/seer/test_night_shift.py | 404 ++----------- 9 files changed, 53 insertions(+), 2100 deletions(-) delete mode 100644 src/sentry/tasks/seer/night_shift/agentic_triage.py delete mode 100644 src/sentry/tasks/seer/night_shift/event_formatter.py delete mode 100644 src/sentry/tasks/seer/night_shift/issue_formatter.py delete mode 100644 src/sentry/tasks/seer/night_shift/triage_tools.py delete mode 100644 tests/sentry/tasks/seer/night_shift/test_event_formatter.py delete mode 100644 tests/sentry/tasks/seer/night_shift/test_issue_formatter.py diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 68cadd888e6c..099a4d6429b0 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -1182,12 +1182,6 @@ default=10, flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "seer.night_shift.use_feature_delivery", - type=Bool, - default=False, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) register( "seer.supergroups_backfill_lightweight.killswitch", diff --git a/src/sentry/tasks/seer/night_shift/agentic_triage.py b/src/sentry/tasks/seer/night_shift/agentic_triage.py deleted file mode 100644 index 82933fd81843..000000000000 --- a/src/sentry/tasks/seer/night_shift/agentic_triage.py +++ /dev/null @@ -1,377 +0,0 @@ -from __future__ import annotations - -import logging -import textwrap -import time -from collections.abc import Sequence - -import pydantic -import sentry_sdk - -from sentry.models.organization import Organization -from sentry.models.project import Project -from sentry.seer.agent.client import SeerAgentClient -from sentry.seer.agent.client_models import SeerRunState -from sentry.seer.models.night_shift import SeerNightShiftRun -from sentry.tasks.seer.night_shift.models import TriageAction, TriageResult -from sentry.tasks.seer.night_shift.simple_triage import ( - ScoredCandidate, - fixability_score_strategy, - priority_label, -) -from sentry.tasks.seer.night_shift.skip_cache import mark_skipped -from sentry.tasks.seer.night_shift.triage_tools import ( - get_event_details_agentic_triage, - get_issue_details_agentic_triage, -) -from sentry.tasks.seer.night_shift.tweaks import ( - DEFAULT_EXTRA_TRIAGE_INSTRUCTIONS, - DEFAULT_INTELLIGENCE_LEVEL, - DEFAULT_REASONING_EFFORT, - IntelligenceLevel, - ReasoningEffort, -) - -logger = logging.getLogger("sentry.tasks.seer.night_shift") - - -class _TriageVerdict(pydantic.BaseModel): - group_id: int - action: TriageAction - reason: str - - -class _TriageResponse(pydantic.BaseModel): - verdicts: list[_TriageVerdict] - - -def agentic_triage_strategy( - projects: Sequence[Project], - organization: Organization, - max_candidates: int, - *, - intelligence_level: IntelligenceLevel = DEFAULT_INTELLIGENCE_LEVEL, - reasoning_effort: ReasoningEffort = DEFAULT_REASONING_EFFORT, - extra_triage_instructions: str = DEFAULT_EXTRA_TRIAGE_INSTRUCTIONS, - run: SeerNightShiftRun, -) -> tuple[list[TriageResult], int | None]: - """ - Select candidates via fixability scoring, then use the Seer Agent - to investigate each candidate and decide the appropriate action. - - Returns a tuple of (triage_results, agent_run_id). - """ - # TODO: try a new way to get scored issues - scored = fixability_score_strategy(projects, max_candidates) - if not scored: - return [], None - - return _triage_candidates( - scored, - organization, - intelligence_level=intelligence_level, - reasoning_effort=reasoning_effort, - extra_triage_instructions=extra_triage_instructions, - run=run, - ) - - -def _triage_candidates( - candidates: list[ScoredCandidate], - organization: Organization, - *, - intelligence_level: IntelligenceLevel, - reasoning_effort: ReasoningEffort, - extra_triage_instructions: str, - run: SeerNightShiftRun, -) -> tuple[list[TriageResult], int | None]: - """ - Start a Seer Agent run to investigate candidate issues and return - triage verdicts. The agent can browse the repo, inspect stacktraces, - and use its tools to make informed decisions. - - Returns a tuple of (triage_results, agent_run_id). - """ - groups_by_id = {c.group.id: c.group for c in candidates} - - try: - client = SeerAgentClient( - organization, - user=None, - category_key="night_shift", - category_value=f"org-{organization.id}", - intelligence_level=intelligence_level, - reasoning_effort=reasoning_effort, - custom_tools=[ - get_event_details_agentic_triage, - get_issue_details_agentic_triage, - ], - ) - - seer_run = client.start_run( - prompt=_build_triage_prompt(candidates, extra_triage_instructions), - artifact_key="triage_verdicts", - artifact_schema=_TriageResponse, - ) - agent_run_id = seer_run.seer_run_state_id - run.update(seer_run=seer_run, extras={**run.extras, "agent_run_id": agent_run_id}) - - logger.info( - "night_shift.explorer_run_started", - extra={ - "organization_id": organization.id, - "agent_run_id": agent_run_id, - "num_candidates": len(candidates), - }, - ) - - state = _poll_with_logging(client, agent_run_id, organization.id) - - triage_response = state.get_artifact("triage_verdicts", _TriageResponse) - if not triage_response: - logger.error( - "night_shift.triage_no_artifact", - extra={ - "organization_id": organization.id, - "agent_run_id": agent_run_id, - "status": state.status, - }, - ) - sentry_sdk.metrics.count( - "night_shift.triage_error", - 1, - attributes={"error_type": "no_artifact"}, - ) - return [], agent_run_id - except Exception: - sentry_sdk.metrics.count( - "night_shift.triage_error", - 1, - attributes={"error_type": "explorer_error"}, - ) - logger.exception( - "night_shift.triage_explorer_error", - extra={"organization_id": organization.id}, - ) - raise - - logger.info( - "night_shift.triage_verdicts", - extra={ - "organization_id": organization.id, - "agent_run_id": agent_run_id, - "verdicts": {v.group_id: v.action for v in triage_response.verdicts}, - }, - ) - - unknown_group_ids = [ - v.group_id for v in triage_response.verdicts if v.group_id not in groups_by_id - ] - if unknown_group_ids: - logger.warning( - "night_shift.triage_unknown_group_ids", - extra={ - "organization_id": organization.id, - "agent_run_id": agent_run_id, - "unknown_group_ids": unknown_group_ids, - }, - ) - - fixability_by_group_id = {c.group.id: c.fixability for c in candidates} - for v in triage_response.verdicts: - if v.group_id not in fixability_by_group_id: - continue - fixability = fixability_by_group_id[v.group_id] - attributes: dict[str, str] = {"action": v.action} - if fixability is not None: - attributes["threshold_action"] = TriageAction.from_fixability_score(fixability) - sentry_sdk.metrics.count( - "night_shift.triage_action", - 1, - attributes=attributes, - ) - - for v in triage_response.verdicts: - if v.group_id in groups_by_id and v.action == TriageAction.SKIP: - mark_skipped(v.group_id) - - return [ - TriageResult(group=groups_by_id[v.group_id], action=v.action, reason=v.reason) - for v in triage_response.verdicts - if v.group_id in groups_by_id and v.action != TriageAction.SKIP - ], agent_run_id - - -POLL_INTERVAL = 2.0 - - -def _poll_with_logging( - client: SeerAgentClient, - agent_run_id: int, - organization_id: int, -) -> SeerRunState: - """Poll an agent run, logging new non-loading blocks as they appear.""" - start_time = time.monotonic() - seen_block_ids: set[str] = set() - - while True: - state = client.get_run(agent_run_id) - - for block in state.blocks: - if block.id in seen_block_ids or block.loading: - continue - seen_block_ids.add(block.id) - - msg = block.message - tool_names = [tc.function for tc in msg.tool_calls] if msg.tool_calls else None - logger.info( - "night_shift.explorer_block", - extra={ - "organization_id": organization_id, - "agent_run_id": agent_run_id, - "block_id": block.id, - "role": msg.role, - "tool_calls": tool_names, - "has_artifacts": bool(block.artifacts), - }, - ) - - if state.status in ("completed", "error", "awaiting_user_input"): - usage = state.usage - logger.info( - "night_shift.explorer_run_completed", - extra={ - "organization_id": organization_id, - "agent_run_id": agent_run_id, - "status": state.status, - "num_blocks": len(state.blocks), - "duration": round(time.monotonic() - start_time, 1), - "dollar_cost": usage.total_dollar_cost, - "usage": [ - { - "model": u.model, - "prompt_tokens": u.prompt_tokens, - "completion_tokens": u.completion_tokens, - "cache_read_tokens": u.prompt_cache_read_tokens, - "cache_write_tokens": u.prompt_cache_write_tokens, - "thinking_tokens": u.thinking_tokens, - "total_tokens": u.total_tokens, - } - for u in usage.usages - ], - }, - ) - return state - - time.sleep(POLL_INTERVAL) - - -def _build_triage_prompt( - candidates: list[ScoredCandidate], - extra_triage_instructions: str, -) -> str: - candidates_block = "\n".join( - f"- group_id={c.group.id} | title={c.group.title or 'Unknown error'!r} " - f"| culprit={c.group.culprit or 'unknown'!r} " - f"| fixability={f'{c.fixability:.2f}' if c.fixability is not None else 'not scored'} | times_seen={c.times_seen} " - f"| first_seen={c.group.first_seen.isoformat()} " - f"| priority={priority_label(c.group.priority) or 'unknown'}" - for c in candidates - ) - - extras_block = ( - f"\n\nAdditional instructions from project owners:\n{extra_triage_instructions}\n" - if extra_triage_instructions - else "" - ) - - return textwrap.dedent(f"""\ - You are a triage agent for Sentry's Night Shift system. Your job is to review - a batch of candidate issues and decide which ones are worth running automated - root-cause analysis and code fixes on. - - Use your tools to investigate each issue — look at all relevant telemetry: the stacktraces, - event logs, event details, breadcrumbs, metrics, and the relevant code in the repository. - - When fetching event data for an issue, always use the `get_event_details_agentic_triage` - tool instead of `get_event_details`. It returns the same data in a cleaner, - more readable format tuned for triage. - - Similarly, when fetching issue-level metadata, always use the - `get_issue_details_agentic_triage` tool instead of `get_issue_details`. - It returns the header, tag distribution, and recent human activity in a - compact markdown format. Do not rely on it for stacktraces — call - `get_event_details_agentic_triage` for that. - - Before recording any verdict, you MUST read the relevant application code. - Surface reading of the error message and stacktrace is not enough — many - errors that look environmental (e.g. "file is not a database", "permission - denied") are actually code bugs once you inspect how the failing code is - called and what assumptions it makes about its inputs/environment. - - When evaluating each issue, consider whether an AI coding agent with full - codebase access could fix the ROOT CAUSE of the issue — not just add try/except or defensive - checks around it. - - The verdicts form a ladder of increasing caution. Default to the LEAST - aggressive verdict that fits, and only step up to `autofix` when you have - cleared a high bar. When you are torn between two verdicts, always pick - the more conservative one (`root_cause_only` over `autofix`, `skip` over - `root_cause_only`). - - Autofix actually opens a code change with no human in the loop before it - is written, so reserve it for issues you are CONVINCED can be fixed - correctly and automatically. Choose `autofix` ONLY when ALL of the - following hold: - - You have pinpointed the exact root cause in application code (specific - file and function), confirmed by reading the code — not a hypothesis. - - There is exactly ONE clearly-correct fix. If several plausible fixes - exist and choosing between them depends on product intent or domain - knowledge you don't have, it is NOT an autofix. - - The fix is small and localized. It does not require a redesign, a new - API or abstraction, a schema/data migration, or coordinated changes - across many files or systems. - - Applying the fix cannot plausibly change intended behavior or make - things worse, and needs no human judgment to confirm it is correct. - - The change is not in a high-blast-radius area — authentication, - permissions/access control, billing or payments, security, money - math, concurrency/locking, or data deletion/migration — unless the fix - is truly trivial and obviously correct. - - You would be comfortable shipping this fix without a human reviewing - the approach first. - If you cannot honestly check every box, do NOT autofix. - - Worth investigating but not safe to auto-fix (-> root_cause_only): - - Likely fixable, but you are not fully confident the fix is correct, or - it needs human judgment, or it touches a high-blast-radius area. - - The fix requires non-trivial investigation, design decisions, or - cross-cutting changes. - - Error originates in third-party libraries, vendor code, or framework internals. - - Root cause is outside the code (filesystem, external services, environment). - This is the default home for anything fixable that doesn't clear the - autofix bar — investigating the root cause is still valuable, and a human - decides what to do with it. - - Not worth processing (-> skip): - - The issue is vague with no actionable stacktrace - - Duplicate of another issue in this batch - - The code is correct but the environment is broken (infra down, DNS failure, - config not provisioned, data corruption) - - The "fixability" score in the candidate data is a prior estimate of how likely - the issue is to be fixable (0.0 = not fixable, 1.0 = very fixable). Issues - marked "not scored" have not been evaluated yet — treat them neutrally rather - than assuming they are unfixable. Use the score as a signal but verify with - your own investigation. A high fixability score is NOT on its own a reason - to autofix — it only means investigation is likely worthwhile. - - For each verdict, fill the `reason` field. For `autofix` and `root_cause_only` - verdicts, the `reason` is handed off as context to the downstream autofix agent - — write it like a debugging note to the next agent. Include the suspected file - and function, the bug mechanism in one or two sentences, and a sketch of the - fix direction so the next agent can resume your investigation instead of - starting over. For `skip` verdicts, a brief justification is sufficient. - - Candidates: - {candidates_block}{extras_block} - """) diff --git a/src/sentry/tasks/seer/night_shift/cron.py b/src/sentry/tasks/seer/night_shift/cron.py index 9d312bc7a71a..85d4c85c08ad 100644 --- a/src/sentry/tasks/seer/night_shift/cron.py +++ b/src/sentry/tasks/seer/night_shift/cron.py @@ -10,7 +10,6 @@ import sentry_sdk from django.utils import timezone -import sentry from sentry import features, options, quotas from sentry.constants import ( SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, @@ -36,7 +35,6 @@ from sentry.seer.models.workflow import SeerWorkflowConfig, SeerWorkflowStrategy from sentry.seer.signed_seer_api import SeerViewerContext from sentry.tasks.base import instrumented_task -from sentry.tasks.seer.night_shift.agentic_triage import agentic_triage_strategy from sentry.tasks.seer.night_shift.models import TriageAction, TriageResult from sentry.tasks.seer.night_shift.simple_triage import fixability_score_strategy, priority_label from sentry.tasks.seer.night_shift.tweaks import ( @@ -304,13 +302,7 @@ def run_night_shift_execution( logger.info("night_shift.no_eligible_projects", extra=log_extra) return None - # sentry.options.get (not options.get) because the `options` param shadows the module here. - if sentry.options.get("seer.night_shift.use_feature_delivery"): - _dispatch_to_seer_feature( - run, organization, eligible, resolved_options, log_extra, start_time - ) - else: - _run_triage_in_process(run, organization, eligible, resolved_options, log_extra, start_time) + _dispatch_to_seer_feature(run, organization, eligible, resolved_options, log_extra, start_time) def _run_option_defaults(data: Mapping[str, Any]) -> SeerNightShiftRunOptions: @@ -512,86 +504,6 @@ def _dispatch_to_seer_feature( ) -def _run_triage_in_process( - run: SeerNightShiftRun, - organization: Organization, - eligible: Sequence[EligibleProject], - resolved_options: SeerNightShiftRunOptions, - log_extra: dict[str, object], - start_time: float, -) -> None: - """DEPRECATED: run the triage agent in-process and trigger autofix directly. - Superseded by _dispatch_to_seer_feature; kept behind the - seer.night_shift.use_feature_delivery option until the cutover is complete.""" - eligible_projects = [ep.project for ep in eligible] - agent_run_id: int | None = None - try: - candidates, agent_run_id = agentic_triage_strategy( - eligible_projects, - organization, - resolved_options["max_candidates"], - intelligence_level=resolved_options["intelligence_level"], - reasoning_effort=resolved_options["reasoning_effort"], - extra_triage_instructions=resolved_options["extra_triage_instructions"], - run=run, - ) - if agent_run_id is not None: - log_extra["agent_run_id"] = agent_run_id - except Exception: - sentry_sdk.metrics.count("night_shift.run_error", 1) - _fail_run( - run, - message="Night shift run failed", - event="night_shift.run_failed", - extra={**log_extra, "agent_run_id": agent_run_id}, - ) - return - - sentry_sdk.metrics.distribution("night_shift.candidates_selected", len(candidates)) - sentry_sdk.metrics.distribution("night_shift.org_run_duration", time.monotonic() - start_time) - - seer_run_id_by_group: dict[int, str | None] = {} - if not resolved_options["dry_run"]: - # Populate each candidate group's FK cache so trigger_autofix_agent doesn't - # re-fetch group.project on every call. Group.organization is a property that - # delegates to self.project.organization, so caching the org on the project is - # enough to avoid both lookups. - projects_by_id = {} - for p in eligible_projects: - p.organization = organization - projects_by_id[p.id] = p - for c in candidates: - c.group.project = projects_by_id[c.group.project_id] - - stopping_point_by_project_id = {ep.project.id: ep.stopping_point for ep in eligible} - results = _run_autofix_for_candidates( - run=run, - candidates=candidates, - stopping_point_by_project_id=stopping_point_by_project_id, - log_extra=log_extra, - ) - seer_run_id_by_group = {r.group_id: r.seer_run_id for r in results} - - logger.info( - "night_shift.candidates_selected", - extra={ - **log_extra, - "num_eligible_projects": len(eligible_projects), - "num_candidates": len(candidates), - "dry_run": resolved_options["dry_run"], - "candidates": [ - { - "group_id": c.group.id, - "action": c.action, - "seer_run_id": seer_run_id_by_group.get(c.group.id), - "num_occurrences": c.group.times_seen, - } - for c in candidates - ], - }, - ) - - def _run_autofix_for_candidates( run: SeerNightShiftRun, candidates: Sequence[TriageResult], diff --git a/src/sentry/tasks/seer/night_shift/event_formatter.py b/src/sentry/tasks/seer/night_shift/event_formatter.py deleted file mode 100644 index bc1b0bcf864e..000000000000 --- a/src/sentry/tasks/seer/night_shift/event_formatter.py +++ /dev/null @@ -1,542 +0,0 @@ -""" -Event formatter for the Night Shift agentic triage tool. - -Ported from sentry-mcp's `formatEventOutput` -(packages/mcp-core/src/internal/formatting.ts). - -Scope: error-event path only — exceptions, threads, message, request, CSP, -tags, contexts, user. The transaction/performance-issue path (N+1 query -evidence, span-tree rendering, slow DB query evidence) and the generic-event -path (performance regression occurrences) are intentionally NOT ported — -they don't apply to error issues, which is the only shape the triage agent -investigates. If a transaction or generic event ever reaches this formatter, -the type-specific sections are silently skipped and the rest of the output -(tags, user, contexts) is still produced. -""" - -from __future__ import annotations - -import json # noqa: S003 - need stdlib json for indent + default kwargs -import re -from collections.abc import Sequence -from typing import Any - -_LANGUAGE_EXTENSIONS: dict[str, str] = { - ".java": "java", - ".py": "python", - ".js": "javascript", - ".jsx": "javascript", - ".ts": "javascript", - ".tsx": "javascript", - ".rb": "ruby", - ".php": "php", -} - -_LANGUAGE_MODULE_PATTERNS: list[tuple[re.Pattern[str], str]] = [ - (re.compile(r"^(java\.|com\.|org\.)"), "java"), -] - - -def format_event_output(event: dict[str, Any]) -> str: - """Render a Sentry event as markdown optimized for LLM consumption.""" - output = "" - user = event.get("user") - entries = event.get("entries") - - if not isinstance(entries, list): - output += _format_event_user(user) - output += _format_tags(event.get("tags")) - output += _format_context(event.get("context")) - output += _format_contexts(event.get("contexts")) - return output - - message_entry = _find_entry(entries, "message") - exception_entry = _find_entry(entries, "exception") - threads_entry = _find_entry(entries, "threads") - request_entry = _find_entry(entries, "request") - csp_entry = _find_entry(entries, "csp") - - if message_entry: - output += _format_message_interface_output(message_entry.get("data") or {}) - - if exception_entry: - output += _format_exception_interface_output(event, exception_entry.get("data") or {}) - elif threads_entry: - output += _format_threads_interface_output(event, threads_entry.get("data") or {}) - - if request_entry: - output += _format_request_interface_output(request_entry.get("data") or {}) - - if csp_entry: - output += _format_csp_interface_output(csp_entry.get("data")) - - # Transaction (type == "transaction") and generic (type == "generic") - # event handling is intentionally omitted — see module docstring. - - output += _format_event_user(user) - output += _format_tags(event.get("tags")) - output += _format_context(event.get("context")) - output += _format_contexts(event.get("contexts")) - return output - - -def _find_entry(entries: list[dict[str, Any]], entry_type: str) -> dict[str, Any] | None: - for entry in entries: - if isinstance(entry, dict) and entry.get("type") == entry_type: - return entry - return None - - -def _format_message_interface_output(data: dict[str, Any]) -> str: - message = data.get("formatted") or data.get("message") or "" - if not message: - return "" - return f"### Error\n\n```\n{message}\n```\n\n" - - -def _format_exception_interface_output(event: dict[str, Any], data: dict[str, Any]) -> str: - values = data.get("values") - if values: - exceptions = list(values) - elif isinstance(data.get("value"), dict): - exceptions = [data["value"]] - else: - return "" - - if not exceptions: - return "" - - is_chained = len(exceptions) > 1 - parts: list[str] = [] - - # Render outermost first: exceptions are innermost-first, so we reverse. - for index, exception in enumerate(reversed(exceptions)): - if not exception: - continue - - if is_chained and index > 0: - parts.append("") - parts.append(_get_exception_chain_message(event.get("platform"), index)) - parts.append("") - - exc_type = exception.get("type") or "" - exc_value = exception.get("value") or "" - title = f"{exc_type}{': ' + exc_value if exc_value else ''}" - - parts.append("### Error" if index == 0 else f"### {title}") - parts.append("") - - if index == 0: - parts.append("```") - parts.append(title) - parts.append("```") - parts.append("") - - stacktrace = exception.get("stacktrace") or {} - frames = stacktrace.get("frames") if isinstance(stacktrace, dict) else None - if not frames: - parts.append("**Stacktrace:**") - parts.append("```") - parts.append("No stacktrace available") - parts.append("```") - continue - - if index == 0: - first_in_app = _find_first_in_app_frame(frames) - if first_in_app and (first_in_app.get("context") or first_in_app.get("vars")): - parts.append(_render_enhanced_frame(first_in_app, event)) - parts.append("") - parts.append("**Full Stacktrace:**") - parts.append("────────────────") - else: - parts.append("**Stacktrace:**") - else: - parts.append("**Stacktrace:**") - - parts.append("```") - parts.append( - "\n".join( - _format_frame_header(frame, None, event.get("platform")) - + _render_inline_context(frame) - for frame in frames - ) - ) - parts.append("```") - - parts.append("") - parts.append("") - return "\n".join(parts) - - -def _format_threads_interface_output(event: dict[str, Any], data: dict[str, Any]) -> str: - values = data.get("values") - if not values: - return "" - - crashed_thread = next( - (t for t in values if isinstance(t, dict) and t.get("crashed")), - None, - ) - if not crashed_thread: - return "" - - stacktrace = crashed_thread.get("stacktrace") or {} - frames = stacktrace.get("frames") if isinstance(stacktrace, dict) else None - if not frames: - return "" - - parts: list[str] = [] - - thread_name = crashed_thread.get("name") - if thread_name: - parts.append(f"**Thread** ({thread_name})") - parts.append("") - - first_in_app = _find_first_in_app_frame(frames) - if first_in_app and (first_in_app.get("context") or first_in_app.get("vars")): - parts.append(_render_enhanced_frame(first_in_app, event)) - parts.append("") - parts.append("**Full Stacktrace:**") - parts.append("────────────────") - else: - parts.append("**Stacktrace:**") - - parts.append("```") - parts.append( - "\n".join( - _format_frame_header(frame, None, event.get("platform")) + _render_inline_context(frame) - for frame in frames - ) - ) - parts.append("```") - parts.append("") - - return "\n".join(parts) - - -def _format_request_interface_output(data: dict[str, Any]) -> str: - method = data.get("method") - url = data.get("url") - if not method or not url: - return "" - return f"### HTTP Request\n\n**Method:** {method}\n**URL:** {url}\n\n" - - -def _format_csp_interface_output(data: Any) -> str: - if not isinstance(data, dict) or not data: - return "" - - parts: list[str] = ["### CSP Violation", ""] - - if data.get("blocked_uri"): - parts.append(f"**Blocked URI**: {data['blocked_uri']}") - if data.get("violated_directive"): - parts.append(f"**Violated Directive**: {data['violated_directive']}") - if data.get("effective_directive"): - parts.append(f"**Effective Directive**: {data['effective_directive']}") - if data.get("document_uri"): - parts.append(f"**Document URI**: {data['document_uri']}") - if data.get("source_file"): - parts.append(f"**Source File**: {data['source_file']}") - if data.get("line_number"): - parts.append(f"**Line Number**: {data['line_number']}") - if data.get("disposition"): - parts.append(f"**Disposition**: {data['disposition']}") - if data.get("original_policy"): - parts.append("") - parts.append("**Original Policy:**") - parts.append("```") - parts.append(str(data["original_policy"])) - parts.append("```") - - parts.append("") - parts.append("") - return "\n".join(parts) - - -def _format_event_user(user: Any) -> str: - if not isinstance(user, dict) or not user: - return "" - - candidates: list[tuple[str, Any]] = [ - ("id", user.get("id")), - ("email", user.get("email")), - ("username", user.get("username")), - ("ip", user.get("ip") or user.get("ip_address")), - ("display_name", user.get("display_name") or user.get("name")), - ] - user_fields = [(k, v) for k, v in candidates if isinstance(v, str) and v] - user_summary = ", ".join(f"{k}:{v}" for k, v in user_fields) - geo_summary = _format_user_geo_summary(user.get("geo")) - - if not user_summary and not geo_summary: - return "" - - output = "### User\n\n" - if user_summary: - output += f"**user**: {user_summary}\n" - if geo_summary: - output += f"**user.geo**: {geo_summary}\n" - return output + "\n" - - -def _format_user_geo_summary(value: Any) -> str | None: - if not isinstance(value, dict): - return None - candidates: list[Any] = [ - value.get("country_code"), - value.get("city"), - value.get("region"), - value.get("country_name"), - ] - parts = [p for p in candidates if isinstance(p, str) and p] - if not parts: - return None - deduped: dict[str, None] = {} - for p in parts: - deduped.setdefault(p, None) - return ", ".join(deduped) - - -def _format_tags(tags: Any) -> str: - if not isinstance(tags, list) or not tags: - return "" - lines = [ - f"**{t['key']}**: {t['value']}" - for t in tags - if isinstance(t, dict) and "key" in t and "value" in t - ] - if not lines: - return "" - return "### Tags\n\n" + "\n".join(lines) + "\n\n" - - -def _format_context(context: Any) -> str: - if not isinstance(context, dict) or not context: - return "" - lines = [f"**{k}**: {json.dumps(v, indent=2, default=str)}" for k, v in context.items()] - return ( - "### Extra Data\n\nAdditional data attached to this event.\n\n" + "\n".join(lines) + "\n\n" - ) - - -def _format_contexts(contexts: Any) -> str: - if not isinstance(contexts, dict) or not contexts: - return "" - blocks: list[str] = [] - for name, data in contexts.items(): - if not isinstance(data, dict): - continue - inner = [ - f"{k}: {json.dumps(v, indent=2, default=str)}" for k, v in data.items() if k != "type" - ] - blocks.append(f"**{name}**\n" + "\n".join(inner)) - if not blocks: - return "" - return ( - "### Additional Context\n\n" - "These are additional context provided by the user when they're " - "instrumenting their application.\n\n" + "\n\n".join(blocks) + "\n\n" - ) - - -def _format_frame_header( - frame: dict[str, Any], frame_index: int | None, platform: str | None -) -> str: - language = _detect_language(frame, platform) - - filename = frame.get("filename") or "" - abs_path = frame.get("absPath") or "" - module = frame.get("module") or "" - function = frame.get("function") or "" - line_no = frame.get("lineNo") - col_no = frame.get("colNo") - - if language == "java": - class_name = module or "UnknownClass" - method = function or "" - source = filename or "Unknown Source" - loc = f":{line_no}" if line_no else "" - return f"at {class_name}.{method}({source}{loc})" - - if language == "python": - file = filename or abs_path or module or "" - func = function or "" - line = f", line {line_no}" if line_no else "" - return f' File "{file}"{line}, in {func}' - - if language == "javascript": - pieces = [str(p) for p in (filename, line_no, col_no) if p] - joined = ":".join(pieces) - suffix = f" ({function})" if function else "" - return f"{joined}{suffix}" - - if language == "ruby": - file = filename or module or "" - func = f" `{function}`" if function else "" - line = f":{line_no}:in" if line_no else "" - return f" from {file}{line}{func}" - - if language == "php": - file = filename or "" - line = f"({line_no})" if line_no else "" - func = function or "" - prefix = f"#{frame_index} " if frame_index is not None else "" - return f"{prefix}{file}{line}: {func}()" - - func = function or "" - location = filename or module or "" - line = f":{line_no}" if line_no else "" - col = f":{col_no}" if col_no is not None else "" - return f" at {func} ({location}{line}{col})" - - -def _detect_language(frame: dict[str, Any], platform: str | None) -> str: - filename = frame.get("filename") - if isinstance(filename, str): - match = re.search(r"\.[^.]+$", filename.lower()) - if match: - ext = match.group(0) - if ext in _LANGUAGE_EXTENSIONS: - return _LANGUAGE_EXTENSIONS[ext] - module = frame.get("module") - if isinstance(module, str): - for pattern, language in _LANGUAGE_MODULE_PATTERNS: - if pattern.match(module): - return language - return platform or "unknown" - - -def _render_inline_context(frame: dict[str, Any]) -> str: - context = frame.get("context") - line_no = frame.get("lineNo") - if not context or not line_no: - return "" - for entry in context: - if isinstance(entry, (list, tuple)) and len(entry) >= 2 and entry[0] == line_no: - return f"\n{entry[1]}" - return "" - - -def _render_enhanced_frame(frame: dict[str, Any], event: dict[str, Any]) -> str: - parts: list[str] = [ - "**Most Relevant Frame:**", - "─────────────────────", - _format_frame_header(frame, None, event.get("platform")), - ] - - if frame.get("context"): - ctx = _render_context_lines(frame) - if ctx: - parts.append("") - parts.append(ctx) - - vars_obj = frame.get("vars") - if isinstance(vars_obj, dict) and vars_obj: - parts.append("") - parts.append(_render_variables_table(vars_obj)) - - return "\n".join(parts) - - -def _render_context_lines(frame: dict[str, Any], context_size: int = 3) -> str: - context = frame.get("context") or [] - error_line = frame.get("lineNo") - if not context or not error_line: - return "" - - pairs: list[tuple[int, str]] = [] - for entry in context: - if isinstance(entry, (list, tuple)) and len(entry) >= 2: - ln, code = entry[0], entry[1] - if isinstance(ln, int): - pairs.append((ln, str(code))) - if not pairs: - return "" - - max_width = max(len(str(ln)) for ln, _ in pairs) - lines: list[str] = [] - for ln, code in pairs: - if abs(ln - error_line) <= context_size: - ln_str = str(ln).rjust(max_width) - if ln == error_line: - lines.append(f" → {ln_str} │ {code}") - else: - lines.append(f" {ln_str} │ {code}") - - return "\n".join(lines) - - -def _format_variable_value(value: Any, max_length: int = 80) -> str: - try: - if isinstance(value, str): - # Overhead for quotes + ellipsis: `"..."` = 5 chars around the content. - if len(value) + 2 > max_length: - return f'"{value[: max_length - 5]}..."' - return f'"{value}"' - if value is None: - return "null" - # Check bool before int — bool is a subclass of int in Python. - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, (int, float)): - return str(value) - if isinstance(value, (list, tuple, dict)): - stringified = json.dumps(value, default=str) - if len(stringified) > max_length: - truncate_at = max_length - 6 - truncated = stringified[:truncate_at] - last_comma = truncated.rfind(",") - if last_comma > 0: - truncated = truncated[:last_comma] - if isinstance(value, (list, tuple)): - return f"{truncated}, ...]" - return f"{truncated}, ...}}" - return stringified - return str(value) - except Exception: - return f"<{type(value).__name__}>" - - -def _render_variables_table(vars_obj: dict[str, Any]) -> str: - entries = list(vars_obj.items()) - if not entries: - return "" - lines = ["Local Variables:"] - last_index = len(entries) - 1 - for idx, (key, value) in enumerate(entries): - prefix = "└─" if idx == last_index else "├─" - lines.append(f"{prefix} {key}: {_format_variable_value(value)}") - return "\n".join(lines) - - -def _find_first_in_app_frame(frames: Sequence[dict[str, Any]]) -> dict[str, Any] | None: - for frame in reversed(list(frames)): - if frame.get("inApp") is True: - return frame - return None - - -def _get_exception_chain_message(platform: str | None, index: int) -> str: - # Exceptions are rendered outermost-first, so the chain message at index > 0 - # introduces an *inner* (causal) exception — "Caused by" reads naturally. - default_msg = "**Caused by:**" - if not platform: - return default_msg - p = platform.lower() - if p == "python": - return default_msg - if p == "java": - return default_msg - if p in ("csharp", "dotnet"): - return "**---> Inner Exception:**" - if p == "ruby": - return default_msg - if p == "go": - return "**Wrapped error:**" - if p == "rust": - # `index` enumerates reversed(exceptions) starting at 0, but the first - # cause arrives here with index=1 (index=0 is the outermost, guarded out - # at the call site). Rust's anyhow numbers causes from 0, so subtract 1. - return f"**Caused by ({index - 1}):**" - return default_msg diff --git a/src/sentry/tasks/seer/night_shift/issue_formatter.py b/src/sentry/tasks/seer/night_shift/issue_formatter.py deleted file mode 100644 index 9b88a651d3ec..000000000000 --- a/src/sentry/tasks/seer/night_shift/issue_formatter.py +++ /dev/null @@ -1,189 +0,0 @@ -""" -Issue formatter for the Night Shift agentic triage tool. - -Triage-tuned: renders the fields from Seer's `get_issue_details` that actually -move the verdict — header (title/culprit/priority/counts/first-last seen), -aggregated tag distribution, and recent human activity. The latest-event -stacktrace excerpt is intentionally omitted so the agent is steered toward -`get_event_details_agentic_triage` (which exposes the full stack, locals on -the first in-app frame, and linked repos). -""" - -from __future__ import annotations - -from collections.abc import Mapping, Sequence -from typing import Any - - -def format_issue_output(details: Mapping[str, Any]) -> str: - """Render Seer's `get_issue_details` response as markdown for triage.""" - issue = details.get("issue") or {} - tags_overview = details.get("tags_overview") or {} - activity = details.get("user_activity") or [] - - sections = [ - _format_header(issue), - _format_tags(tags_overview), - _format_activity(activity), - ] - return "\n\n".join(s for s in sections if s) + "\n" - - -def _format_header(issue: Mapping[str, Any]) -> str: - short_id = issue.get("shortId") or "(no short id)" - title = issue.get("title") or "Unknown" - lines = [f"# {short_id}: {title}"] - - culprit = issue.get("culprit") - if culprit: - lines.append(f"**Culprit:** `{culprit}`") - - issue_type = issue.get("issueType") - issue_category = issue.get("issueCategory") - type_desc = issue.get("issueTypeDescription") - type_parts = [p for p in (issue_type, issue_category) if p] - if type_parts: - suffix = f" — {type_desc}" if type_desc else "" - lines.append(f"**Type:** {' / '.join(type_parts)}{suffix}") - - status_bits = [ - ("Level", issue.get("level")), - ("Priority", issue.get("priority")), - ("Status", issue.get("status")), - ("Substatus", issue.get("substatus")), - ] - status_line = " | ".join(f"**{k}:** {v}" for k, v in status_bits if v) - if status_line: - lines.append(status_line) - - first_seen = issue.get("firstSeen") - last_seen = issue.get("lastSeen") - if first_seen or last_seen: - seen_bits = [] - if first_seen: - seen_bits.append(f"**First seen:** {first_seen}") - if last_seen: - seen_bits.append(f"**Last seen:** {last_seen}") - lines.append(" | ".join(seen_bits)) - - count = issue.get("count") - user_count = issue.get("userCount") - count_bits = [] - if count is not None: - count_bits.append(f"**Events:** {count}") - if user_count is not None: - count_bits.append(f"**Users affected:** {user_count}") - if count_bits: - lines.append(" | ".join(count_bits)) - - assigned_to = issue.get("assignedTo") - assignee = _format_actor(assigned_to) if assigned_to else None - if assignee: - lines.append(f"**Assigned to:** {assignee}") - - platform = issue.get("platform") - if platform: - lines.append(f"**Platform:** {platform}") - - return "\n".join(lines) - - -def _format_tags(tags_overview: Mapping[str, Any]) -> str: - # Seer returns {"tags_overview": [...tags...]} under the response's - # "tags_overview" key — unwrap the inner list. - inner = tags_overview.get("tags_overview") if isinstance(tags_overview, Mapping) else None - if not isinstance(inner, Sequence) or not inner: - return "" - - blocks = [] - for tag in inner: - if not isinstance(tag, Mapping): - continue - key = tag.get("key") - if not key: - continue - name = tag.get("name") or key - total = tag.get("total_values") - unique = tag.get("unique_values") - - meta_bits = [] - if total is not None: - meta_bits.append(f"{total} total") - if unique is not None: - meta_bits.append(f"{unique} unique") - meta = f" ({', '.join(meta_bits)})" if meta_bits else "" - - header = f"**{name}** (`{key}`){meta}" if name != key else f"**{key}**{meta}" - - top_values = tag.get("top_values") or [] - value_lines = [] - for tv in top_values: - if not isinstance(tv, Mapping): - continue - value = tv.get("value") - count = tv.get("count") - pct = tv.get("percentage") - pct_str = f" ({pct})" if pct is not None else "" - count_str = f" — {count}" if count is not None else "" - value_lines.append(f"- {value}{count_str}{pct_str}") - - if not value_lines: - continue - blocks.append(header + "\n" + "\n".join(value_lines)) - - if not blocks: - return "" - return "## Tag Distribution\n\n" + "\n\n".join(blocks) - - -def _format_activity(activity: Sequence[Any]) -> str: - if not activity: - return "" - - lines = [] - for item in activity: - if not isinstance(item, Mapping): - continue - when = item.get("dateCreated") - actor = _format_actor(item.get("user")) or _format_actor(item.get("sentry_app")) or "system" - type_ = item.get("type") or "activity" - detail = _summarize_activity_data(item.get("data")) - when_str = f"{when} — " if when else "" - detail_str = f": {detail}" if detail else "" - lines.append(f"- {when_str}{actor} ({type_}){detail_str}") - - if not lines: - return "" - return "## Recent Activity\n\n" + "\n".join(lines) - - -def _format_actor(actor: Any) -> str | None: - if not isinstance(actor, Mapping): - return None - for key in ("email", "username", "name"): - value = actor.get(key) - if isinstance(value, str) and value: - return value - return None - - -def _summarize_activity_data(data: Any) -> str: - if not isinstance(data, Mapping) or not data: - return "" - preferred_keys = ( - "text", - "assignee", - "assigneeEmail", - "version", - "commit", - "pullRequest", - ) - for key in preferred_keys: - value = data.get(key) - if isinstance(value, str) and value: - return f"{key}={value}" - if isinstance(value, Mapping): - nested = value.get("id") or value.get("shortId") or value.get("title") - if nested: - return f"{key}={nested}" - return "" diff --git a/src/sentry/tasks/seer/night_shift/triage_tools.py b/src/sentry/tasks/seer/night_shift/triage_tools.py deleted file mode 100644 index fb429291b917..000000000000 --- a/src/sentry/tasks/seer/night_shift/triage_tools.py +++ /dev/null @@ -1,223 +0,0 @@ -from __future__ import annotations - -from django.core.exceptions import BadRequest -from pydantic import BaseModel, Field - -from sentry.integrations.models.repository_project_path_config import ( - RepositoryProjectPathConfig, -) -from sentry.models.group import Group -from sentry.models.organization import Organization -from sentry.seer.agent.custom_tool_utils import AgentTool -from sentry.seer.agent.tools import get_event_details, get_issue_details -from sentry.tasks.seer.night_shift.event_formatter import format_event_output -from sentry.tasks.seer.night_shift.issue_formatter import format_issue_output - - -class GetEventDetailsAgenticTriageParams(BaseModel): - event_id: str | None = Field( - default=None, - description="The UUID of the event (mutually exclusive with issue_id).", - ) - issue_id: str | None = Field( - default=None, - description=( - "The issue ID (numeric) or qualified short ID (mutually exclusive with event_id)." - ), - ) - start: str | None = Field( - default=None, - description=( - "ISO timestamp for the start of the time range to get a recommended " - "event for. Must be provided together with end." - ), - ) - end: str | None = Field( - default=None, - description=( - "ISO timestamp for the end of the time range. Must be provided together with start." - ), - ) - project_slug: str | None = Field( - default=None, - description="The slug of the project (optional).", - ) - - -# Class name intentionally snake_case — the agent custom-tool machinery -# uses `__name__` as the tool name the agent sees, and we want this to read -# like a tool identifier (`get_event_details_agentic_triage`). -class get_event_details_agentic_triage( # noqa: N801 - AgentTool[GetEventDetailsAgenticTriageParams] -): - """Custom agent tool for Night Shift agentic triage. - - Returns the same underlying event data as Seer's built-in `get_event_details`, - but renders it in the markdown format used by sentry-mcp (`formatEventOutput`) - instead of Seer's `...` text block. Optimized for LLM - consumption during triage: drops the per-frame SUSPECT LINE markers, caps - variable values at ~80 chars, and only shows enhanced context + locals on - the first in-app frame. - """ - - params_model = GetEventDetailsAgenticTriageParams - - @classmethod - def get_description(cls) -> str: - return ( - "Fetch details for a sample event or occurrence of a Sentry issue, " - "or look up a specific event by its UUID. Returns markdown-formatted " - "event data: stack trace with inline code context, local variables " - "on the most relevant frame, tags, contexts, HTTP request, and user.\n\n" - "Supports two modes:\n" - "- Query by issue_id to get a recommended sample event for that issue. " - "Optionally provide start/end to sample from a specific time range.\n" - "- Query by event_id (32-character UUID) to fetch a specific event directly." - ) - - @classmethod - def execute( - cls, - organization: Organization, - params: GetEventDetailsAgenticTriageParams, - ) -> str: - try: - result = get_event_details( - organization_id=organization.id, - event_id=params.event_id, - issue_id=params.issue_id, - start=params.start, - end=params.end, - project_slug=params.project_slug, - ) - except (BadRequest, Group.DoesNotExist, ValueError) as e: - # All three are agent-correctable: bad arg combo, missing group, or - # malformed UUID. Return the exception message so the agent can see - # what went wrong and retry. Unexpected errors (Organization.DoesNotExist, - # AssertionError, etc.) bubble up to be logged as real failures. - return f"Could not fetch event: {e}" - if result is None: - return "Event not found. Check the issue_id/event_id and time range." - - event = result.get("event") or {} - body = format_event_output(event) - return ( - f"Event ID: {result.get('event_id')}\n" - f"Trace ID: {result.get('event_trace_id') or 'N/A'}\n" - f"Issue ID: {event.get('groupID') or 'N/A'}\n" - f"Project ID: {result.get('project_id')}\n" - f"Project Slug: {result.get('project_slug')}\n" - f"\n{body}" - ) - - -class GetIssueDetailsAgenticTriageParams(BaseModel): - issue_id: str = Field( - description="The issue ID (numeric) or qualified short ID (e.g. PROJECT-123).", - ) - start: str | None = Field( - default=None, - description=( - "ISO timestamp for the start of the time range. Must be provided together with end." - ), - ) - end: str | None = Field( - default=None, - description=( - "ISO timestamp for the end of the time range. Must be provided together with start." - ), - ) - project_slug: str | None = Field( - default=None, - description="The slug of the project (optional).", - ) - - -# Class name intentionally snake_case — see comment on `get_event_details_agentic_triage`. -class get_issue_details_agentic_triage( # noqa: N801 - AgentTool[GetIssueDetailsAgenticTriageParams] -): - """Custom agent tool for Night Shift agentic triage. - - Returns the same underlying issue metadata as Seer's built-in `get_issue_details`, - but reformats it as triage-tuned markdown: header (title/culprit/priority/counts/ - first-last seen), linked repos, aggregated tag distribution, and recent human - activity. The latest-event stacktrace excerpt is intentionally omitted so the - agent is steered toward `get_event_details_agentic_triage` for stack inspection. - """ - - params_model = GetIssueDetailsAgenticTriageParams - - @classmethod - def get_description(cls) -> str: - return ( - "Fetch issue-level metadata for a Sentry issue: title, culprit, " - "level/priority/status, first/last seen, event and user counts, " - "assignee, linked repositories (repo name + source/stack roots), " - "aggregated tag distribution across all events, and recent human " - "activity (notes, assignments, resolutions).\n\n" - "Use this to decide whether an issue is worth investigating. For " - "the actual stacktrace and failing code context, call " - "`get_event_details_agentic_triage` separately." - ) - - @classmethod - def execute( - cls, - organization: Organization, - params: GetIssueDetailsAgenticTriageParams, - ) -> str: - try: - result = get_issue_details( - organization_id=organization.id, - issue_id=params.issue_id, - start=params.start, - end=params.end, - project_slug=params.project_slug, - ) - except (BadRequest, Group.DoesNotExist, ValueError) as e: - return f"Could not fetch issue: {e}" - if result is None: - return "Issue not found. Check the issue_id and time range." - - project_id = result.get("project_id") - linked_repos = _format_linked_repos(project_id, organization) if project_id else "" - body = format_issue_output(result) - return ( - f"Issue ID: {params.issue_id}\n" - f"Project ID: {project_id}\n" - f"Project Slug: {result.get('project_slug')}\n" - f"{linked_repos}" - f"\n{body}" - ) - - -def _format_linked_repos(project_id: int, organization: Organization) -> str: - """Render the project's linked GitHub repos + source-root mappings. - - Surfaces the actual repo name (e.g. `getsentry/seer-test-sandbox`) so the - agent doesn't have to guess it from the project slug. Includes source_root - because many repos place app code under a subdirectory (e.g. `python/`). - """ - configs = ( - RepositoryProjectPathConfig.objects.filter(project_repository__project_id=project_id) - .select_related("project_repository__repository") - .order_by("id") - ) - lines: list[str] = [] - for cfg in configs: - repo_name = cfg.project_repository.repository.name - source_root = cfg.source_root or "" - stack_root = cfg.stack_root or "" - parts = [f"- {repo_name}"] - if source_root: - parts.append(f"source_root={source_root!r}") - if stack_root: - parts.append(f"stack_root={stack_root!r}") - lines.append( - " ".join(parts) if len(parts) == 1 else parts[0] + " (" + ", ".join(parts[1:]) + ")" - ) - - if not lines: - return "" - return "Linked Repos:\n" + "\n".join(lines) + "\n" diff --git a/tests/sentry/tasks/seer/night_shift/test_event_formatter.py b/tests/sentry/tasks/seer/night_shift/test_event_formatter.py deleted file mode 100644 index f2a9246eac60..000000000000 --- a/tests/sentry/tasks/seer/night_shift/test_event_formatter.py +++ /dev/null @@ -1,206 +0,0 @@ -import textwrap - -from sentry.tasks.seer.night_shift.event_formatter import format_event_output - - -def test_error_event_comprehensive() -> None: - # One realistic Python error event exercising the main path: exception with - # stacktrace + enhanced frame (context + locals), HTTP request, user with - # geo dedup, tags, contexts. Truncation of a long string local pins the - # variable-value cap. - event = { - "platform": "python", - "entries": [ - { - "type": "exception", - "data": { - "values": [ - { - "type": "ValueError", - "value": "bad input", - "stacktrace": { - "frames": [ - { - "filename": "src/app.py", - "function": "do_thing", - "lineNo": 10, - "inApp": True, - "context": [ - (9, "def do_thing():"), - (10, " raise ValueError('bad input')"), - (11, " return 1"), - ], - "vars": { - "x": "broken", - "long_str": "a" * 200, - }, - } - ], - }, - } - ], - }, - }, - {"type": "request", "data": {"method": "POST", "url": "https://api.example.com/x"}}, - ], - "user": { - "email": "alice@example.com", - "geo": {"country_code": "US", "city": "NYC", "region": "US"}, - }, - "tags": [{"key": "env", "value": "prod"}], - "contexts": {"runtime": {"name": "CPython", "version": "3.12.0"}}, - } - - output = format_event_output(event) - - expected_frame_block = ( - textwrap.dedent( - """ - **Most Relevant Frame:** - ───────────────────── - File "src/app.py", line 10, in do_thing - - 9 │ def do_thing(): - → 10 │ raise ValueError('bad input') - 11 │ return 1 - - Local Variables: - ├─ x: "broken" - └─ long_str: "{long_a}..." - """ - ) - .strip() - .format(long_a="a" * 75) - ) - assert expected_frame_block in output - - expected_request_block = textwrap.dedent( - """ - ### HTTP Request - - **Method:** POST - **URL:** https://api.example.com/x - """ - ).strip() - assert expected_request_block in output - - # User block — `geo` dedupes "US" appearing in both country_code and region. - expected_user_block = textwrap.dedent( - """ - ### User - - **user**: email:alice@example.com - **user.geo**: US, NYC - """ - ).strip() - assert expected_user_block in output - - expected_tags_block = textwrap.dedent( - """ - ### Tags - - **env**: prod - """ - ).strip() - assert expected_tags_block in output - - expected_contexts_block = textwrap.dedent( - """ - ### Additional Context - - These are additional context provided by the user when they're instrumenting their application. - - **runtime** - name: "CPython" - version: "3.12.0" - """ - ).strip() - assert expected_contexts_block in output - - assert "### Error" in output - assert "ValueError: bad input" in output - assert "**Full Stacktrace:**" in output - - -def test_chained_exceptions_rendered_outer_first() -> None: - # Sentry stores exceptions innermost-first; the formatter reverses so the - # rethrown/outer exception renders first, then "Caused by:" for the inner. - event = { - "platform": "python", - "entries": [ - { - "type": "exception", - "data": { - "values": [ - { - "type": "KeyError", - "value": "'missing'", - "stacktrace": {"frames": [{"filename": "a.py", "function": "f"}]}, - }, - { - "type": "RuntimeError", - "value": "wrapper failed", - "stacktrace": {"frames": [{"filename": "b.py", "function": "g"}]}, - }, - ], - }, - } - ], - } - - output = format_event_output(event) - - error_idx = output.index("### Error") - caused_by_idx = output.index("**Caused by:**") - inner_header_idx = output.index("### KeyError: 'missing'") - assert error_idx < caused_by_idx < inner_header_idx - assert "RuntimeError: wrapper failed" in output - - -def test_alt_interfaces_threads_message_and_csp() -> None: - # Threads, message, and CSP each drive an independent code path. They don't - # normally coexist in a real event, but they don't conflict in the - # formatter either, so we exercise all three in one fixture. - event = { - "platform": "javascript", - "entries": [ - {"type": "message", "data": {"formatted": "something went wrong"}}, - { - "type": "threads", - "data": { - "values": [ - { - "crashed": True, - "name": "main", - "stacktrace": {"frames": [{"filename": "x.js", "function": "crash"}]}, - } - ], - }, - }, - { - "type": "csp", - "data": { - "blocked_uri": "https://evil.example.com/a.js", - "violated_directive": "script-src", - "document_uri": "https://app.example.com/", - }, - }, - ], - } - - output = format_event_output(event) - - expected_csp_block = textwrap.dedent( - """ - ### CSP Violation - - **Blocked URI**: https://evil.example.com/a.js - **Violated Directive**: script-src - **Document URI**: https://app.example.com/ - """ - ).strip() - assert expected_csp_block in output - - assert "something went wrong" in output - assert "**Thread** (main)" in output - assert "crash" in output diff --git a/tests/sentry/tasks/seer/night_shift/test_issue_formatter.py b/tests/sentry/tasks/seer/night_shift/test_issue_formatter.py deleted file mode 100644 index e8a7cbfc0c41..000000000000 --- a/tests/sentry/tasks/seer/night_shift/test_issue_formatter.py +++ /dev/null @@ -1,116 +0,0 @@ -import textwrap - -from sentry.tasks.seer.night_shift.issue_formatter import format_issue_output - - -def test_full_output_comprehensive() -> None: - # Full populated Seer response: header with all fields, tag overview wrapped - # in the nested {"tags_overview": {"tags_overview": [...]}} shape that the - # Seer RPC actually returns, and mixed activity (assigned + note + system- - # actor fallback). - details = { - "issue": { - "shortId": "PROJ-123", - "title": "ValueError: bad input", - "culprit": "app.foo in bar", - "issueType": "error", - "issueCategory": "error", - "issueTypeDescription": "Error", - "level": "error", - "priority": "high", - "status": "unresolved", - "substatus": "new", - "firstSeen": "2026-01-01T00:00:00Z", - "lastSeen": "2026-01-02T00:00:00Z", - "count": 42, - "userCount": 7, - "platform": "python", - "assignedTo": {"email": "alice@example.com"}, - }, - "tags_overview": { - "tags_overview": [ - { - "key": "browser", - "name": "Browser", - "total_values": 100, - "unique_values": 3, - "top_values": [ - {"value": "Chrome 120", "count": 70, "percentage": "70%"}, - {"value": "Firefox 121", "count": 30, "percentage": "30%"}, - ], - } - ], - }, - "user_activity": [ - { - "type": "assigned", - "user": {"email": "bob@example.com"}, - "data": {"assigneeEmail": "carol@example.com"}, - "dateCreated": "2026-01-03T12:00:00Z", - }, - { - "type": "note", - "user": {"username": "alice"}, - "data": {"text": "looks like a regression"}, - "dateCreated": "2026-01-03T13:00:00Z", - }, - {"type": "set_resolved", "user": None, "dateCreated": "2026-01-04T00:00:00Z"}, - ], - } - - output = format_issue_output(details) - - expected_header = textwrap.dedent( - """ - # PROJ-123: ValueError: bad input - **Culprit:** `app.foo in bar` - **Type:** error / error — Error - **Level:** error | **Priority:** high | **Status:** unresolved | **Substatus:** new - **First seen:** 2026-01-01T00:00:00Z | **Last seen:** 2026-01-02T00:00:00Z - **Events:** 42 | **Users affected:** 7 - **Assigned to:** alice@example.com - **Platform:** python - """ - ).strip() - assert expected_header in output - - expected_tags_block = textwrap.dedent( - """ - ## Tag Distribution - - **Browser** (`browser`) (100 total, 3 unique) - - Chrome 120 — 70 (70%) - - Firefox 121 — 30 (30%) - """ - ).strip() - assert expected_tags_block in output - - expected_activity_block = textwrap.dedent( - """ - ## Recent Activity - - - 2026-01-03T12:00:00Z — bob@example.com (assigned): assigneeEmail=carol@example.com - - 2026-01-03T13:00:00Z — alice (note): text=looks like a regression - - 2026-01-04T00:00:00Z — system (set_resolved) - """ - ).strip() - assert expected_activity_block in output - - -def test_sparse_input_omits_missing_sections() -> None: - # Minimal data: sparse issue, empty tag overview (missing inner key), empty - # activity list. Optional sections should be silently omitted. - output = format_issue_output( - { - "issue": {"shortId": "P-1", "title": "Oops"}, - "tags_overview": {}, - "user_activity": [], - } - ) - - assert "# P-1: Oops" in output - assert "Culprit" not in output - assert "Type" not in output - assert "Assigned to" not in output - assert "## Tag Distribution" not in output - assert "## Recent Activity" not in output diff --git a/tests/sentry/tasks/seer/test_night_shift.py b/tests/sentry/tasks/seer/test_night_shift.py index 2781d9c09649..a51c3a7edf8e 100644 --- a/tests/sentry/tasks/seer/test_night_shift.py +++ b/tests/sentry/tasks/seer/test_night_shift.py @@ -1,13 +1,8 @@ -import itertools -from collections.abc import Callable, Iterator -from contextlib import contextmanager -from unittest.mock import MagicMock, patch +from unittest.mock import patch from sentry.models.group import Group from sentry.models.organization import OrganizationStatus -from sentry.seer.agent.client_models import Artifact, MemoryBlock, Message, SeerRunState from sentry.seer.autofix.constants import AutofixAutomationTuningSettings -from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunResult from sentry.seer.models.workflow import SeerWorkflowStrategy from sentry.tasks.seer.night_shift.cron import ( @@ -20,42 +15,11 @@ from sentry.tasks.seer.night_shift.skip_cache import key as skip_cache_key from sentry.tasks.seer.night_shift.skip_cache import mark_skipped from sentry.testutils.cases import SnubaTestCase, TestCase -from sentry.testutils.factories import Factories from sentry.testutils.helpers.datetime import before_now from sentry.testutils.pytest.fixtures import django_db_all from sentry.utils.redis import redis_clusters -class FakeExplorerClient: - """Stub SeerAgentClient that returns canned triage verdicts.""" - - def __init__(self, verdicts: list[tuple[int, str]]): - verdict_dicts = [ - {"group_id": gid, "action": action, "reason": "test"} for gid, action in verdicts - ] - artifact = Artifact(key="triage_verdicts", data={"verdicts": verdict_dicts}, reason="test") - self._state = SeerRunState( - run_id=1, - blocks=[ - MemoryBlock( - id="test-block", - message=Message(role="assistant"), - timestamp="2025-01-01T00:00:00", - artifacts=[artifact], - ), - ], - status="completed", - updated_at="2025-01-01T00:00:00", - ) - self.organization = None - - def start_run(self, **kwargs): - return Factories.create_seer_run(self.organization, seer_run_state_id=self._state.run_id) - - def get_run(self, run_id, **kwargs): - return self._state - - @django_db_all class TestScheduleNightShift(TestCase): def create_org_with_seer(self): @@ -268,37 +232,6 @@ def _store_event_and_update_group(self, project, fingerprint, **group_attrs): Group.objects.filter(id=event.group_id).update(**group_attrs) return Group.objects.get(id=event.group_id) - @contextmanager - def _patched_night_shift( - self, - verdicts: list[tuple[int, str]], - *, - trigger: Callable | None = None, - ) -> Iterator[tuple[MagicMock, MagicMock]]: - # Default trigger assigns sequential Seer run ids (100, 101, ...) so each - # candidate gets a distinct id, matching real-world behavior. - counter = itertools.count(100) - side_effect = trigger if trigger is not None else (lambda **kwargs: next(counter)) - - fake_client = FakeExplorerClient(verdicts) - - def _build_client(organization, *args, **kwargs): - fake_client.organization = organization - return fake_client - - with ( - patch( - "sentry.tasks.seer.night_shift.agentic_triage.SeerAgentClient", - side_effect=_build_client, - ), - patch( - "sentry.tasks.seer.night_shift.cron.trigger_autofix_agent", - side_effect=side_effect, - ) as mock_trigger, - patch("sentry.tasks.seer.night_shift.cron.logger") as mock_logger, - ): - yield mock_trigger, mock_logger - def test_nonexistent_org(self) -> None: with patch("sentry.tasks.seer.night_shift.cron.logger") as mock_logger: run_night_shift_for_org(999999999) @@ -335,180 +268,6 @@ def test_eligible_projects_error_records_error_message(self) -> None: assert run.extras["error_message"] == "Failed to get eligible projects" assert not SeerNightShiftRunResult.objects.filter(run=run).exists() - def test_selects_candidates_and_skips_triggered(self) -> None: - org = self.create_organization() - project = self.create_project(organization=org) - self._make_eligible(project) - - high_fix = self._store_event_and_update_group( - project, "high-fix", seer_fixability_score=0.9, times_seen=5 - ) - low_fix = self._store_event_and_update_group( - project, "low-fix", seer_fixability_score=0.5, times_seen=100 - ) - # Already triggered — should be excluded from triage. - self._store_event_and_update_group( - project, - "triggered", - seer_fixability_score=0.95, - seer_autofix_last_triggered=before_now(minutes=5), - ) - - verdicts = [(high_fix.id, "autofix"), (low_fix.id, "autofix")] - with self._patched_night_shift(verdicts) as (_mock_trigger, mock_logger): - run_night_shift_for_org(org.id) - - call_extra = mock_logger.info.call_args.kwargs["extra"] - assert call_extra["num_candidates"] == 2 - candidates = call_extra["candidates"] - assert candidates[0]["group_id"] == high_fix.id - assert candidates[1]["group_id"] == low_fix.id - # Both candidates were triggered and each carries its own Seer run id. - assert candidates[0]["seer_run_id"] == "100" - assert candidates[1]["seer_run_id"] == "101" - - run = SeerNightShiftRun.objects.get(organization=org) - assert run.extras.get("error_message") is None - assert run.extras == { - "options": { - "source": "cron", - "max_candidates": 10, - "dry_run": False, - "intelligence_level": "high", - "reasoning_effort": "high", - "extra_triage_instructions": "", - }, - "agent_run_id": 1, - } - - result_group_ids = set( - SeerNightShiftRunResult.objects.filter(run=run, kind="agentic_triage").values_list( - "group_id", flat=True - ) - ) - assert result_group_ids == {high_fix.id, low_fix.id} - - def test_explorer_triage_error_propagates_to_run(self) -> None: - org = self.create_organization() - project = self.create_project(organization=org) - self._make_eligible(project) - - self._store_event_and_update_group( - project, "fixable", seer_fixability_score=0.9, times_seen=5 - ) - - mock_client = MagicMock() - mock_client.start_run.side_effect = RuntimeError("explorer down") - with ( - patch( - "sentry.tasks.seer.night_shift.agentic_triage.SeerAgentClient", - return_value=mock_client, - ), - ): - run_night_shift_for_org(org.id) - - run = SeerNightShiftRun.objects.get(organization=org) - assert run.extras["error_message"] == "Night shift run failed" - assert not SeerNightShiftRunResult.objects.filter(run=run).exists() - - def test_triggers_autofix_with_correct_stopping_point(self) -> None: - org = self.create_organization() - project = self.create_project(organization=org) - self._make_eligible(project) - project.update_option( - "sentry:seer_automated_run_stopping_point", AutofixStoppingPoint.OPEN_PR.value - ) - - autofix_group = self._store_event_and_update_group( - project, "autofix", seer_fixability_score=0.9, times_seen=5 - ) - root_cause_group = self._store_event_and_update_group( - project, "root-cause", seer_fixability_score=0.8, times_seen=5 - ) - - run_ids = {autofix_group.id: 42, root_cause_group.id: 99} - verdicts = [(autofix_group.id, "autofix"), (root_cause_group.id, "root_cause_only")] - with self._patched_night_shift( - verdicts, trigger=lambda **kwargs: run_ids[kwargs["group"].id] - ) as (mock_trigger, _): - run_night_shift_for_org(org.id) - - stopping_points_by_group = { - call.kwargs["group"].id: call.kwargs["stopping_point"] - for call in mock_trigger.call_args_list - } - assert stopping_points_by_group[autofix_group.id] == AutofixStoppingPoint.OPEN_PR - assert stopping_points_by_group[root_cause_group.id] == AutofixStoppingPoint.ROOT_CAUSE - - run = SeerNightShiftRun.objects.get(organization=org) - result_run_ids = dict( - SeerNightShiftRunResult.objects.filter(run=run, kind="agentic_triage").values_list( - "group_id", "seer_run_id" - ) - ) - assert result_run_ids == {autofix_group.id: "42", root_cause_group.id: "99"} - - def test_autofix_stopping_point_honors_project_preference(self) -> None: - org = self.create_organization() - project = self.create_project(organization=org) - self._make_eligible(project) - project.update_option( - "sentry:seer_automated_run_stopping_point", AutofixStoppingPoint.SOLUTION.value - ) - - group = self._store_event_and_update_group( - project, "autofix", seer_fixability_score=0.9, times_seen=5 - ) - - with self._patched_night_shift([(group.id, "autofix")]) as (mock_trigger, _): - run_night_shift_for_org(org.id) - - assert mock_trigger.call_args.kwargs["stopping_point"] == AutofixStoppingPoint.SOLUTION - - def test_dry_run_skips_autofix(self) -> None: - org = self.create_organization() - project = self.create_project(organization=org) - self._make_eligible(project) - - group = self._store_event_and_update_group( - project, "fixable", seer_fixability_score=0.9, times_seen=5 - ) - - with self._patched_night_shift([(group.id, "autofix")]) as (mock_trigger, mock_logger): - run_night_shift_for_org(org.id, options={"dry_run": True}) - - mock_trigger.assert_not_called() - call_extra = mock_logger.info.call_args.kwargs["extra"] - assert call_extra["dry_run"] is True - assert call_extra["candidates"][0]["seer_run_id"] is None - - # Dry runs don't perform any Seer work, so no result rows are written. - run = SeerNightShiftRun.objects.get(organization=org) - assert SeerNightShiftRunResult.objects.filter(run=run).count() == 0 - - def test_skips_autofix_for_skip_candidates(self) -> None: - org = self.create_organization() - project = self.create_project(organization=org) - self._make_eligible(project) - - group = self._store_event_and_update_group( - project, "skip-me", seer_fixability_score=0.9, times_seen=5 - ) - - with ( - self._patched_night_shift([(group.id, "skip")]) as (mock_trigger, mock_logger), - patch("sentry.tasks.seer.night_shift.agentic_triage.mark_skipped") as mock_mark_skipped, - ): - run_night_shift_for_org(org.id) - - mock_trigger.assert_not_called() - log_calls = [call.args[0] for call in mock_logger.info.call_args_list] - assert "night_shift.no_fixable_candidates" in log_calls - mock_mark_skipped.assert_called_once_with(group.id) - - run = SeerNightShiftRun.objects.get(organization=org) - assert not SeerNightShiftRunResult.objects.filter(run=run).exists() - def test_filters_recently_skipped_groups(self) -> None: org = self.create_organization() project = self.create_project(organization=org) @@ -523,15 +282,20 @@ def test_filters_recently_skipped_groups(self) -> None: mark_skipped(skipped_group.id) try: - with self._patched_night_shift([(other_group.id, "autofix")]) as (mock_trigger, _): + with patch( + "sentry.tasks.seer.night_shift.cron.trigger_seer_feature", + return_value=4242, + ) as mock_trigger: run_night_shift_for_org(org.id) finally: redis_clusters.get("default").delete(skip_cache_key(skipped_group.id)) mock_trigger.assert_called_once() - assert mock_trigger.call_args.kwargs["group"].id == other_group.id + request = mock_trigger.call_args.args[0] + candidate_ids = [c["group_id"] for c in request["payload"]["candidates"]] + assert candidate_ids == [other_group.id] - def test_skips_autofix_when_no_seer_quota(self) -> None: + def test_skips_dispatch_when_no_seer_quota(self) -> None: org = self.create_organization() project = self.create_project(organization=org) self._make_eligible(project) @@ -545,53 +309,15 @@ def test_skips_autofix_when_no_seer_quota(self) -> None: "sentry.tasks.seer.night_shift.cron.quotas.backend.check_seer_quota", return_value=False, ), - patch("sentry.tasks.seer.night_shift.cron.agentic_triage_strategy") as mock_triage, - patch( - "sentry.tasks.seer.night_shift.cron.trigger_autofix_agent", - ) as mock_trigger, + patch("sentry.tasks.seer.night_shift.cron.trigger_seer_feature") as mock_trigger, ): run_night_shift_for_org(org.id) - - # Triage and trigger are both skipped when the org has no quota. - mock_triage.assert_not_called() mock_trigger.assert_not_called() run = SeerNightShiftRun.objects.get(organization=org) assert run.extras["error_message"] == "No Seer quota available" assert not SeerNightShiftRunResult.objects.filter(run=run).exists() - def test_skips_issue_row_on_trigger_failure(self) -> None: - org = self.create_organization() - project = self.create_project(organization=org) - self._make_eligible(project) - - raising_group = self._store_event_and_update_group( - project, "raises", seer_fixability_score=0.9, times_seen=5 - ) - ok_group = self._store_event_and_update_group( - project, "ok", seer_fixability_score=0.8, times_seen=5 - ) - - def trigger(**kwargs): - if kwargs["group"].id == raising_group.id: - raise RuntimeError("explorer crash") - return 7 - - verdicts = [(raising_group.id, "autofix"), (ok_group.id, "autofix")] - with self._patched_night_shift(verdicts, trigger=trigger) as (_, mock_logger): - run_night_shift_for_org(org.id) - - exception_calls = [call.args[0] for call in mock_logger.exception.call_args_list] - assert "night_shift.autofix_trigger_failed" in exception_calls - - run = SeerNightShiftRun.objects.get(organization=org) - result_run_ids = dict( - SeerNightShiftRunResult.objects.filter(run=run, kind="agentic_triage").values_list( - "group_id", "seer_run_id" - ) - ) - assert result_run_ids == {ok_group.id: "7"} - def test_max_candidates_defaults_to_global_option(self) -> None: org = self.create_organization() low = self.create_project(organization=org, slug="low") @@ -599,32 +325,28 @@ def test_max_candidates_defaults_to_global_option(self) -> None: self._make_eligible(low, max_candidates=3) self._make_eligible(high, max_candidates=11) - with ( - patch( - "sentry.tasks.seer.night_shift.cron.agentic_triage_strategy", - return_value=([], None), - ) as mock_triage, - ): + with patch( + "sentry.tasks.seer.night_shift.cron.fixability_score_strategy", + return_value=[], + ) as mock_score: run_night_shift_for_org(org.id) - mock_triage.assert_called_once() - assert mock_triage.call_args.args[2] == 10 + mock_score.assert_called_once() + assert mock_score.call_args.args[1] == 10 def test_explicit_max_candidates_overrides_tweaks(self) -> None: org = self.create_organization() project = self.create_project(organization=org) self._make_eligible(project, max_candidates=50) - with ( - patch( - "sentry.tasks.seer.night_shift.cron.agentic_triage_strategy", - return_value=([], None), - ) as mock_triage, - ): + with patch( + "sentry.tasks.seer.night_shift.cron.fixability_score_strategy", + return_value=[], + ) as mock_score: run_night_shift_for_org(org.id, options={"max_candidates": 7}) - mock_triage.assert_called_once() - assert mock_triage.call_args.args[2] == 7 + mock_score.assert_called_once() + assert mock_score.call_args.args[1] == 7 def test_scheduler_skips_projects_with_tweaks_disabled(self) -> None: org = self.create_organization() @@ -633,22 +355,20 @@ def test_scheduler_skips_projects_with_tweaks_disabled(self) -> None: self._make_eligible(enabled) self._make_eligible(disabled, enabled=False) - with ( - patch( - "sentry.tasks.seer.night_shift.cron.agentic_triage_strategy", - return_value=([], None), - ) as mock_triage, - ): + with patch( + "sentry.tasks.seer.night_shift.cron.fixability_score_strategy", + return_value=[], + ) as mock_score: run_night_shift_for_org(org.id) - mock_triage.assert_called_once() - assert [p.id for p in mock_triage.call_args.args[0]] == [enabled.id] + mock_score.assert_called_once() + assert [p.id for p in mock_score.call_args.args[0]] == [enabled.id] @django_db_all class TestRunNightShiftFeatureDelivery(TestCase, SnubaTestCase): - """Coverage for the seer.night_shift.use_feature_delivery path, which hands - triage off to Seer's feature-run endpoint instead of running it in-process.""" + """Coverage for the dispatch path, which hands triage off to Seer's + feature-run endpoint. Seer pushes verdicts back via deliver_feature_result.""" reset_snuba_data = False @@ -682,25 +402,21 @@ def test_dispatches_candidates_to_seer_feature(self) -> None: ) with ( - self.options({"seer.night_shift.use_feature_delivery": True}), patch( "sentry.tasks.seer.night_shift.cron.trigger_seer_feature", return_value=4242, ) as mock_trigger, - patch("sentry.tasks.seer.night_shift.cron.agentic_triage_strategy") as mock_in_process, patch("sentry.tasks.seer.night_shift.cron.trigger_autofix_agent") as mock_autofix, ): run_night_shift_for_org(org.id) - # The deprecated in-process path is bypassed entirely. - mock_in_process.assert_not_called() + # Autofix is fired by Seer's pushed-back verdicts, not in-process. mock_autofix.assert_not_called() mock_trigger.assert_called_once() request = mock_trigger.call_args.args[0] assert request["feature_id"] == "night_shift" assert [c["group_id"] for c in request["payload"]["candidates"]] == [group.id] - # Priority is sent as a label ("high"), matching the in-process path. assert request["payload"]["candidates"][0]["priority"] == "high" assert mock_trigger.call_args.kwargs["viewer_context"] == {"organization_id": org.id} @@ -717,10 +433,7 @@ def test_no_candidates_skips_dispatch(self) -> None: project = self.create_project(organization=org) self._make_eligible(project) - with ( - self.options({"seer.night_shift.use_feature_delivery": True}), - patch("sentry.tasks.seer.night_shift.cron.trigger_seer_feature") as mock_trigger, - ): + with patch("sentry.tasks.seer.night_shift.cron.trigger_seer_feature") as mock_trigger: run_night_shift_for_org(org.id) mock_trigger.assert_not_called() @@ -736,12 +449,9 @@ def test_seer_feature_error_records_error_message(self) -> None: project, "fixable", seer_fixability_score=0.9, times_seen=5 ) - with ( - self.options({"seer.night_shift.use_feature_delivery": True}), - patch( - "sentry.tasks.seer.night_shift.cron.trigger_seer_feature", - side_effect=RuntimeError("seer down"), - ), + with patch( + "sentry.tasks.seer.night_shift.cron.trigger_seer_feature", + side_effect=RuntimeError("seer down"), ): run_night_shift_for_org(org.id) @@ -801,15 +511,11 @@ def test_extras_contain_options_and_target_project_ids(self) -> None: org = self.create_organization() project = self.create_project(organization=org) - with patch( - "sentry.tasks.seer.night_shift.cron.agentic_triage_strategy", - return_value=([], None), - ): - run_night_shift_for_org( - org.id, - options={"source": "manual", "dry_run": True, "max_candidates": 5}, - project_ids=[project.id], - ) + run_night_shift_for_org( + org.id, + options={"source": "manual", "dry_run": True, "max_candidates": 5}, + project_ids=[project.id], + ) run = SeerNightShiftRun.objects.get(organization=org) assert run.extras == { @@ -828,16 +534,12 @@ def test_extras_contain_triggering_user_id_when_provided(self) -> None: org = self.create_organization() project = self.create_project(organization=org) - with patch( - "sentry.tasks.seer.night_shift.cron.agentic_triage_strategy", - return_value=([], None), - ): - run_night_shift_for_org( - org.id, - options={"source": "manual", "dry_run": True}, - project_ids=[project.id], - triggering_user_id=4242, - ) + run_night_shift_for_org( + org.id, + options={"source": "manual", "dry_run": True}, + project_ids=[project.id], + triggering_user_id=4242, + ) run = SeerNightShiftRun.objects.get(organization=org) assert run.extras["triggering_user_id"] == 4242 @@ -852,16 +554,14 @@ def test_manual_runs_even_when_project_tweak_is_disabled(self) -> None: self.create_seer_project_repository(project=project, repository=repo) project.update_option("sentry:seer_nightshift_tweaks", {"enabled": False}) - with ( - patch( - "sentry.tasks.seer.night_shift.cron.agentic_triage_strategy", - return_value=([], None), - ) as mock_triage, - ): + with patch( + "sentry.tasks.seer.night_shift.cron.fixability_score_strategy", + return_value=[], + ) as mock_score: run_night_shift_for_org(org.id, options={"source": "manual"}, project_ids=[project.id]) - mock_triage.assert_called_once() - assert [p.id for p in mock_triage.call_args.args[0]] == [project.id] + mock_score.assert_called_once() + assert [p.id for p in mock_score.call_args.args[0]] == [project.id] @django_db_all