From 07a9035c99ea3301be5b2e5f7531341e65a22bf6 Mon Sep 17 00:00:00 2001 From: Arsalan Bashir Date: Wed, 16 Sep 2026 10:44:44 -0700 Subject: [PATCH 1/2] fix(investigations): Preserve query context through refinement --- src/sentry/investigations/agent.py | 31 +++++- .../investigations/services/executions.py | 59 ++++++++++- .../services/orchestration_events.py | 1 + .../services/test_executions.py | 99 +++++++++++++++++++ .../services/test_orchestration_events.py | 1 + tests/sentry/investigations/test_agent.py | 21 +++- 6 files changed, 206 insertions(+), 6 deletions(-) diff --git a/src/sentry/investigations/agent.py b/src/sentry/investigations/agent.py index db9e315819c3..9a407fdeee64 100644 --- a/src/sentry/investigations/agent.py +++ b/src/sentry/investigations/agent.py @@ -26,7 +26,10 @@ InvestigationStatus, ) from sentry.investigations.services.auto_run import schedule_eligible_auto_run_blocks -from sentry.investigations.services.executions import mark_block_execution_dispatched +from sentry.investigations.services.executions import ( + freeze_query_links, + mark_block_execution_dispatched, +) from sentry.investigations.services.investigations import ( DEFAULT_INVESTIGATION_TITLE, investigation_source, @@ -123,6 +126,17 @@ class TitleGenerationStatus(StrEnum): When notebookContext contains an item with currentBlock=true, it is the last successful result for the block being refined. Reuse its table and chart data for presentation-only requests such as changing line, area, or bar visualization; do not claim the data is unavailable or query it again. +Keep the same measurements, filters, and time window when changing presentation. For a query +change such as a different grouping, use the absolute start/end in that result's queryLinks. +The current block's queryContext preserves its original source, filters, and parameters when +query links are unavailable; its source may contain timeRange or an analysisWindow, either +directly or inside snapshot. These saved settings take precedence over current page filters. +Include the exact start/end timestamps in every new telemetry question. Change the time window +only when the user's new request explicitly asks for a different period. Never reinterpret an +old "last 6 days" label relative to today or infer a query window from the first/last chart point. +If the original window is unavailable, reuse the saved data for presentation changes and ask +for the window before querying again. Preserve the current result if a requested transformation +cannot be performed from its saved data. The first character must be { and the last character must be }. Do not wrap the object in a Markdown code fence or include prose before or after it. Do not call any function to write or save the result. tableMarkdown must be a complete Markdown table (or an empty table). When @@ -876,7 +890,20 @@ def synchronize_execution(execution: InvestigationBlockExecution, state: SeerRun links, projects = _successful_links_and_projects( state, execution.block.investigation.organization ) - result["queryLinks"] = links + if links: + result["queryLinks"] = freeze_query_links( + links, reference_time=execution.started_at or execution.date_added + ) + else: + previous_result: dict[str, Any] = next( + ( + item["result"] + for item in execution.input_snapshot.get("context", []) + if item.get("currentBlock") is True + ), + {}, + ) + result["queryLinks"] = previous_result.get("queryLinks", []) result = validate_query_result(result) allowed_project_ids = set(execution.input_snapshot.get("projectIds", [])) queried_project_ids = {project.id for project in projects} diff --git a/src/sentry/investigations/services/executions.py b/src/sentry/investigations/services/executions.py index 595386a6c148..d04e98f3e326 100644 --- a/src/sentry/investigations/services/executions.py +++ b/src/sentry/investigations/services/executions.py @@ -36,6 +36,7 @@ from sentry.investigations.telemetry import record_execution_cancelled from sentry.models.project import Project from sentry.utils import json +from sentry.utils.dates import parse_stats_period MAX_CONTEXT_BLOCKS = 20 MAX_CONTEXT_TEXT_CHARS = 50_000 @@ -48,6 +49,35 @@ def _fingerprint(snapshot: dict[str, Any]) -> str: return hashlib.sha256(serialized.encode()).hexdigest() +def freeze_query_time_range(params: dict[str, Any], *, reference_time: datetime) -> dict[str, Any]: + """Anchor relative windows to the execution that produced the saved data.""" + frozen = dict(params) + if not (frozen.get("start") and frozen.get("end")): + period = frozen.get("stats_period") or frozen.get("statsPeriod") + if not isinstance(period, str) or frozen.get("start") or frozen.get("end"): + return frozen + duration = parse_stats_period(period) + if duration is None or duration <= timedelta(): + return frozen + try: + start = reference_time - duration + except OverflowError: + return frozen + frozen.update(start=start.isoformat(), end=reference_time.isoformat()) + frozen.pop("stats_period", None) + frozen.pop("statsPeriod", None) + return frozen + + +def freeze_query_links( + links: list[dict[str, Any]], *, reference_time: datetime +) -> list[dict[str, Any]]: + return [ + {**link, "params": freeze_query_time_range(link["params"], reference_time=reference_time)} + for link in links + ] + + def _compact_query_context( result: Any, *, max_text_chars: int = MAX_CONTEXT_TEXT_CHARS ) -> dict[str, Any]: @@ -272,6 +302,8 @@ def build_block_execution_snapshot( raise InvestigationValidationError({"detail": "The template dataset hint is invalid."}) prompt = (block.prompt or block.content).strip() + source = investigation_source(block.investigation) + filters = investigation_filters(block.investigation) parameters: dict[str, Any] = {} for link in block.parameter_links.select_related("parameter").order_by("parameter__key"): parameter = link.parameter @@ -290,6 +322,7 @@ def build_block_execution_snapshot( except ParameterValidationError as error: raise InvestigationValidationError({"parameters": {parameter.key: str(error)}}) parameters[parameter.key] = value + query_context = {"source": source, "filters": filters, "parameters": parameters} if block.kind == InvestigationBlockKind.TEXT: dependencies, context, context_project_ids = _materialize_notebook_context( block, accessible_project_ids=accessible_project_ids @@ -310,6 +343,23 @@ def build_block_execution_snapshot( raise InvestigationValidationError( {"context": "The previous query result uses inaccessible project data."} ) + reference_time = previous_execution.started_at or previous_execution.date_added + previous_input = previous_execution.input_snapshot + query_context = previous_input.get("queryContext") or { + "source": previous_input.get("source", source), + "filters": previous_input.get("filters", filters), + "parameters": previous_input.get("parameters", parameters), + } + query_context = { + **query_context, + "filters": freeze_query_time_range( + query_context.get("filters", {}), reference_time=reference_time + ), + } + previous_result = _compact_query_context(previous_execution.result) + previous_result["queryLinks"] = freeze_query_links( + previous_result["queryLinks"], reference_time=reference_time + ) context.insert( 0, { @@ -318,7 +368,8 @@ def build_block_execution_snapshot( "title": block.title, "currentBlock": True, "visibleExecutionId": str(previous_execution.id), - "result": _compact_query_context(previous_execution.result), + "result": previous_result, + "queryContext": query_context, }, ) context_project_ids = sorted(set(context_project_ids).union(previous_project_ids)) @@ -329,8 +380,8 @@ def build_block_execution_snapshot( snapshot: dict[str, Any] = { "prompt": prompt, "organizationSlug": block.investigation.organization.slug, - "source": investigation_source(block.investigation), - "filters": investigation_filters(block.investigation), + "source": source, + "filters": filters, "parameters": parameters, "dependencies": dependencies, "context": context, @@ -342,6 +393,8 @@ def build_block_execution_snapshot( } if dataset_hint is not None: snapshot["datasetHint"] = dataset_hint + if block.kind == InvestigationBlockKind.QUERY: + snapshot["queryContext"] = query_context return snapshot, _fingerprint(snapshot) diff --git a/src/sentry/investigations/services/orchestration_events.py b/src/sentry/investigations/services/orchestration_events.py index 80c5faabde2d..d2b2e0787a6a 100644 --- a/src/sentry/investigations/services/orchestration_events.py +++ b/src/sentry/investigations/services/orchestration_events.py @@ -614,6 +614,7 @@ def _upsert_report_block( "reportRevision": revision, "stableAgentKey": key, "projectIds": project_ids, + "source": deepcopy(run.source), } now = timezone.now() completed_at = None diff --git a/tests/sentry/investigations/services/test_executions.py b/tests/sentry/investigations/services/test_executions.py index 13e4f5691441..8c4d9365d9b3 100644 --- a/tests/sentry/investigations/services/test_executions.py +++ b/tests/sentry/investigations/services/test_executions.py @@ -189,6 +189,105 @@ def test_rejects_invalid_query_dataset_hint(self) -> None: with pytest.raises(InvestigationValidationError): self.run_block(block) + def test_refinement_keeps_the_original_window_after_ten_days(self) -> None: + original_end = timezone.now() - timedelta(days=10) + original_start = original_end - timedelta(days=6) + self.investigation.update(filters={"statsPeriod": "6d"}) + block = self.create_block(prompt="Show a bar chart instead") + previous = self.create_execution( + block, + started_at=original_end, + input_snapshot={"filters": {"statsPeriod": "6d", "environment": ["production"]}}, + ) + original_link = { + "kind": "telemetry", + "params": {"dataset": "errors", "query": "", "stats_period": "6d"}, + } + previous.update(result={**previous.result, "queryLinks": [original_link]}) + previous.data_projects.add(self.project) + block.update(result_execution=previous) + + snapshot, _ = build_block_execution_snapshot( + block=block, projects=[self.project], accessible_project_ids={self.project.id} + ) + + current = snapshot["context"][0] + assert current["result"]["queryLinks"] == [ + { + "kind": "telemetry", + "params": { + "dataset": "errors", + "query": "", + "start": original_start.isoformat(), + "end": original_end.isoformat(), + }, + } + ] + assert snapshot["queryContext"]["filters"] == { + "environment": ["production"], + "start": original_start.isoformat(), + "end": original_end.isoformat(), + } + previous.refresh_from_db() + assert previous.result["queryLinks"] == [original_link] + + refined = self.create_execution( + block, + started_at=timezone.now(), + input_snapshot=snapshot, + result=current["result"], + ) + refined.data_projects.add(self.project) + block.update(result_execution=refined) + self.investigation.update(filters={"statsPeriod": "24h"}) + + next_snapshot, _ = build_block_execution_snapshot( + block=block, projects=[self.project], accessible_project_ids={self.project.id} + ) + + assert ( + next_snapshot["context"][0]["result"]["queryLinks"] == current["result"]["queryLinks"] + ) + assert next_snapshot["queryContext"] == snapshot["queryContext"] + + def test_refinement_keeps_explicit_query_bounds_and_original_source(self) -> None: + time_range = {"start": "2025-08-01T00:00:00Z", "end": "2025-08-07T00:00:00Z"} + source = {"type": "manual", "timeRange": time_range} + block = self.create_block() + previous = self.create_execution(block, input_snapshot={"source": source}) + link = {"kind": "telemetry", "params": {"dataset": "errors", **time_range}} + previous.update(result={**previous.result, "queryLinks": [link]}) + previous.data_projects.add(self.project) + block.update(result_execution=previous) + self.investigation.update(source={"type": "manual", "prompt": "Changed source"}) + + snapshot, _ = build_block_execution_snapshot( + block=block, projects=[self.project], accessible_project_ids={self.project.id} + ) + + assert snapshot["context"][0]["queryContext"]["source"] == source + assert snapshot["context"][0]["result"]["queryLinks"] == [link] + + def test_relative_filters_keep_the_same_request_fingerprint(self) -> None: + self.investigation.update(filters={"statsPeriod": "6d"}) + block = self.create_block() + started_at = timezone.now() + with mock.patch( + "sentry.investigations.services.executions.timezone.now", return_value=started_at + ): + _, first = build_block_execution_snapshot( + block=block, projects=[self.project], accessible_project_ids={self.project.id} + ) + with mock.patch( + "sentry.investigations.services.executions.timezone.now", + return_value=started_at + timedelta(minutes=1), + ): + _, retry = build_block_execution_snapshot( + block=block, projects=[self.project], accessible_project_ids={self.project.id} + ) + + assert first == retry + def test_rejects_duplicate_project_scope(self) -> None: block = self.create_block() with pytest.raises(InvestigationValidationError): diff --git a/tests/sentry/investigations/services/test_orchestration_events.py b/tests/sentry/investigations/services/test_orchestration_events.py index 10802e94585a..b681c3123f38 100644 --- a/tests/sentry/investigations/services/test_orchestration_events.py +++ b/tests/sentry/investigations/services/test_orchestration_events.py @@ -255,6 +255,7 @@ def test_execution_timestamps_survive_completion_and_snapshot_replay(self) -> No execution_id = block.current_execution_id assert block.current_execution.started_at == started_at assert block.current_execution.completed_at == completed_at + assert block.current_execution.input_snapshot["source"] == self.orchestration_run.source self.deliver( self.event( diff --git a/tests/sentry/investigations/test_agent.py b/tests/sentry/investigations/test_agent.py index cfd6b7541fa0..e0523469d5b7 100644 --- a/tests/sentry/investigations/test_agent.py +++ b/tests/sentry/investigations/test_agent.py @@ -157,7 +157,7 @@ def test_completed_query_keeps_result_and_source_projects(self) -> None: content=( '\n' "{'result': '12 errors', 'link_params': " - "{'dataset': 'errors', 'query': 'is:unresolved', " + "{'dataset': 'errors', 'query': 'is:unresolved', 'stats_period': '6d', " f"'project_slugs': ['{self.project.slug}']}}}}\n" "" ), @@ -186,6 +186,10 @@ def test_completed_query_keeps_result_and_source_projects(self) -> None: assert self.execution.status == InvestigationBlockExecutionStatus.COMPLETED assert self.execution.result["tableMarkdown"].startswith("| Errors |") assert self.execution.result["queryLinks"][0]["kind"] == "telemetry" + params = self.execution.result["queryLinks"][0]["params"] + assert params["start"] == (self.execution.date_added - timedelta(days=6)).isoformat() + assert params["end"] == self.execution.date_added.isoformat() + assert "stats_period" not in params assert list(self.execution.data_projects.all()) == [self.project] assert self.block.result_execution == self.execution @@ -214,8 +218,22 @@ def test_completed_text_keeps_snapshotted_context_projects(self) -> None: assert list(self.execution.data_projects.all()) == [self.project] def test_completed_query_keeps_reused_result_projects(self) -> None: + original_links = [ + { + "kind": "telemetry", + "params": { + "dataset": "errors", + "query": "", + "start": "2025-08-01T00:00:00Z", + "end": "2025-08-07T00:00:00Z", + }, + } + ] self.execution.input_snapshot["projectIds"] = [] self.execution.input_snapshot["contextDataProjectIds"] = [self.project.id] + self.execution.input_snapshot["context"] = [ + {"currentBlock": True, "result": {"queryLinks": original_links}} + ] self.execution.save(update_fields=["input_snapshot"]) run_state = state( blocks=[ @@ -239,6 +257,7 @@ def test_completed_query_keeps_reused_result_projects(self) -> None: self.execution.refresh_from_db() assert self.execution.status == InvestigationBlockExecutionStatus.COMPLETED assert list(self.execution.data_projects.all()) == [self.project] + assert self.execution.result["queryLinks"] == original_links def test_start_run_requests_a_final_response_without_an_artifact_writer(self) -> None: client = MagicMock() From ec269f772a0d6b5f1e7471930a36a76f706ae242 Mon Sep 17 00:00:00 2001 From: Arsalan Bashir Date: Thu, 17 Sep 2026 12:00:37 -0700 Subject: [PATCH 2/2] fix(investigations): Honor explicit refinement parameter edits --- src/sentry/investigations/agent.py | 29 ++-- .../investigations/services/executions.py | 21 ++- .../services/test_executions.py | 154 +++++++++++++++++- tests/sentry/investigations/test_agent.py | 27 +++ 4 files changed, 211 insertions(+), 20 deletions(-) diff --git a/src/sentry/investigations/agent.py b/src/sentry/investigations/agent.py index 9a407fdeee64..3f7b577ed39c 100644 --- a/src/sentry/investigations/agent.py +++ b/src/sentry/investigations/agent.py @@ -124,18 +124,24 @@ class TitleGenerationStatus(StrEnum): template parameter. Use source.snapshot for supplied monitor, project, threshold, condition, dataset, and analysis-window facts; do not report them missing merely because parameters is empty. When notebookContext contains an item with currentBlock=true, it is the last successful result for -the block being refined. Reuse its table and chart data for presentation-only requests such as -changing line, area, or bar visualization; do not claim the data is unavailable or query it again. -Keep the same measurements, filters, and time window when changing presentation. For a query -change such as a different grouping, use the absolute start/end in that result's queryLinks. -The current block's queryContext preserves its original source, filters, and parameters when -query links are unavailable; its source may contain timeRange or an analysisWindow, either -directly or inside snapshot. These saved settings take precedence over current page filters. -Include the exact start/end timestamps in every new telemetry question. Change the time window -only when the user's new request explicitly asks for a different period. Never reinterpret an +the block being refined. Its queryContext contains the saved source; do not substitute today's source. +For presentation-only requests without data-changing parameter edits, reuse its table and chart data +(for example, changing line, area, or bar visualization); do not query it again. +Keep the same measurements, filters, and time window when changing presentation. parameterChanges +lists linked parameter values changed since the saved result, including null for a cleared value. +Apply those explicit edits and the user's new request over the saved settings, including queryLinks; +a parameter edit that changes the data requires a new query even if the request also changes presentation. +Unchanged parameters and current page filters do not override the saved query settings. +For a query change such as a different grouping, preserve the original time window unless the request +or a changed time parameter explicitly supplies a different period. Resolve the original window from +the result's queryLinks first, then the current block's queryContext.source (timeRange or analysisWindow, +directly or inside snapshot), then queryContext.filters saved with that execution. queryContext also +preserves the previous parameter values. Missing historical settings are unknown; never combine +current page filters with an old execution time to invent a window. +Include the exact start/end timestamps in every new telemetry question. Never reinterpret an old "last 6 days" label relative to today or infer a query window from the first/last chart point. -If the original window is unavailable, reuse the saved data for presentation changes and ask -for the window before querying again. Preserve the current result if a requested transformation +If neither a saved nor an explicitly requested window is available, reuse saved data for presentation +changes and ask for the window before querying again. Preserve the current result if a requested transformation cannot be performed from its saved data. The first character must be { and the last character must be }. Do not wrap the object in a Markdown code fence or include prose before or after it. Do not call any function to @@ -172,6 +178,7 @@ def build_agent_prompt(execution: InvestigationBlockExecution) -> str: "projectSlugs": snapshot.get("projectSlugs", []), "filters": snapshot.get("filters", {}), "parameters": snapshot.get("parameters", {}), + "parameterChanges": snapshot.get("parameterChanges", {}), "notebookContext": snapshot.get("context", []), "datasetHint": snapshot.get("datasetHint"), } diff --git a/src/sentry/investigations/services/executions.py b/src/sentry/investigations/services/executions.py index d04e98f3e326..c25643b53e98 100644 --- a/src/sentry/investigations/services/executions.py +++ b/src/sentry/investigations/services/executions.py @@ -323,6 +323,7 @@ def build_block_execution_snapshot( raise InvestigationValidationError({"parameters": {parameter.key: str(error)}}) parameters[parameter.key] = value query_context = {"source": source, "filters": filters, "parameters": parameters} + parameter_changes: dict[str, Any] = {} if block.kind == InvestigationBlockKind.TEXT: dependencies, context, context_project_ids = _materialize_notebook_context( block, accessible_project_ids=accessible_project_ids @@ -346,9 +347,17 @@ def build_block_execution_snapshot( reference_time = previous_execution.started_at or previous_execution.date_added previous_input = previous_execution.input_snapshot query_context = previous_input.get("queryContext") or { - "source": previous_input.get("source", source), - "filters": previous_input.get("filters", filters), - "parameters": previous_input.get("parameters", parameters), + "source": previous_input.get("source", {}), + "filters": previous_input.get("filters", {}), + "parameters": previous_input.get("parameters", {}), + } + previous_parameters = previous_input.get( + "parameters", query_context.get("parameters", {}) + ) + parameter_changes = { + key: value + for key, value in parameters.items() + if key not in previous_parameters or value != previous_parameters[key] } query_context = { **query_context, @@ -394,7 +403,11 @@ def build_block_execution_snapshot( if dataset_hint is not None: snapshot["datasetHint"] = dataset_hint if block.kind == InvestigationBlockKind.QUERY: - snapshot["queryContext"] = query_context + snapshot["parameterChanges"] = parameter_changes + snapshot["queryContext"] = { + **query_context, + "parameters": {**query_context.get("parameters", {}), **parameter_changes}, + } return snapshot, _fingerprint(snapshot) diff --git a/tests/sentry/investigations/services/test_executions.py b/tests/sentry/investigations/services/test_executions.py index 8c4d9365d9b3..f009fb03021a 100644 --- a/tests/sentry/investigations/services/test_executions.py +++ b/tests/sentry/investigations/services/test_executions.py @@ -203,6 +203,7 @@ def test_refinement_keeps_the_original_window_after_ten_days(self) -> None: "kind": "telemetry", "params": {"dataset": "errors", "query": "", "stats_period": "6d"}, } + assert previous.result is not None previous.update(result={**previous.result, "queryLinks": [original_link]}) previous.data_projects.add(self.project) block.update(result_execution=previous) @@ -229,6 +230,7 @@ def test_refinement_keeps_the_original_window_after_ten_days(self) -> None: "end": original_end.isoformat(), } previous.refresh_from_db() + assert previous.result is not None assert previous.result["queryLinks"] == [original_link] refined = self.create_execution( @@ -256,6 +258,7 @@ def test_refinement_keeps_explicit_query_bounds_and_original_source(self) -> Non block = self.create_block() previous = self.create_execution(block, input_snapshot={"source": source}) link = {"kind": "telemetry", "params": {"dataset": "errors", **time_range}} + assert previous.result is not None previous.update(result={**previous.result, "queryLinks": [link]}) previous.data_projects.add(self.project) block.update(result_execution=previous) @@ -268,25 +271,166 @@ def test_refinement_keeps_explicit_query_bounds_and_original_source(self) -> Non assert snapshot["context"][0]["queryContext"]["source"] == source assert snapshot["context"][0]["result"]["queryLinks"] == [link] + def test_report_refinement_does_not_anchor_current_filters_to_old_execution(self) -> None: + source = { + "type": "manual", + "timeRange": {"start": "2025-08-01T00:00:00Z", "end": "2025-08-07T00:00:00Z"}, + } + self.investigation.update(filters={"statsPeriod": "24h", "environment": ["staging"]}) + block = self.create_block() + previous = self.create_execution( + block, + started_at=timezone.now() - timedelta(days=10), + input_snapshot={"orchestrationRunId": 1, "source": source}, + ) + block.update(result_execution=previous) + + snapshot, _ = build_block_execution_snapshot( + block=block, projects=[self.project], accessible_project_ids={self.project.id} + ) + + assert snapshot["queryContext"] == {"source": source, "filters": {}, "parameters": {}} + assert snapshot["context"][0]["queryContext"] == snapshot["queryContext"] + assert snapshot["parameterChanges"] == {} + + def test_refinement_leaves_missing_historical_context_unknown(self) -> None: + self.investigation.update( + filters={"statsPeriod": "24h"}, + source={ + "type": "manual", + "timeRange": {"start": "2025-08-01T00:00:00Z", "end": "2025-08-07T00:00:00Z"}, + }, + ) + block = self.create_block() + previous = self.create_execution(block, input_snapshot={}) + block.update(result_execution=previous) + + snapshot, _ = build_block_execution_snapshot( + block=block, projects=[self.project], accessible_project_ids={self.project.id} + ) + + assert snapshot["queryContext"] == {"source": {}, "filters": {}, "parameters": {}} + assert snapshot["context"][0]["result"]["queryLinks"] == [] + + def test_refinement_tracks_parameter_edits_without_changing_the_saved_window(self) -> None: + original_end = timezone.now() - timedelta(days=10) + block = self.create_block() + environment = self.create_investigation_parameter( + investigation=self.investigation, + key="environment", + label="Environment", + position=0, + type=InvestigationParameterType.ENVIRONMENT_LIST, + saved_value=["staging"], + ) + self.create_investigation_block_parameter(block=block, parameter=environment) + query = self.create_investigation_parameter( + investigation=self.investigation, + key="query", + label="Query", + position=1, + type=InvestigationParameterType.STRING, + saved_value="error.type:TimeoutError", + ) + self.create_investigation_block_parameter(block=block, parameter=query) + previous_parameters = {"environment": ["production"], "query": query.saved_value} + previous = self.create_execution( + block, + started_at=original_end, + input_snapshot={"filters": {"statsPeriod": "6d"}, "parameters": previous_parameters}, + ) + block.update(result_execution=previous) + self.investigation.update(filters={"statsPeriod": "24h"}) + + snapshot, _ = build_block_execution_snapshot( + block=block, projects=[self.project], accessible_project_ids={self.project.id} + ) + + assert snapshot["parameterChanges"] == {"environment": ["staging"]} + assert snapshot["context"][0]["queryContext"]["parameters"] == previous_parameters + assert snapshot["queryContext"]["parameters"] == snapshot["parameters"] + assert snapshot["queryContext"]["filters"] == { + "start": (original_end - timedelta(days=6)).isoformat(), + "end": original_end.isoformat(), + } + refined = self.create_execution(block, input_snapshot=snapshot) + block.update(result_execution=refined) + + next_snapshot, _ = build_block_execution_snapshot( + block=block, projects=[self.project], accessible_project_ids={self.project.id} + ) + + assert next_snapshot["parameterChanges"] == {} + assert next_snapshot["context"][0]["queryContext"] == snapshot["queryContext"] + environment.update(saved_value=None) + + cleared_snapshot, _ = build_block_execution_snapshot( + block=block, projects=[self.project], accessible_project_ids={self.project.id} + ) + + assert cleared_snapshot["parameterChanges"] == {"environment": None} + assert cleared_snapshot["queryContext"]["parameters"]["environment"] is None + + def test_refinement_identifies_an_explicit_time_parameter_edit(self) -> None: + original_window = {"start": "2025-08-01T00:00:00Z", "end": "2025-08-07T00:00:00Z"} + requested_window = {"start": "2025-08-10T00:00:00Z", "end": "2025-08-16T00:00:00Z"} + block = self.create_block() + parameter = self.create_investigation_parameter( + investigation=self.investigation, + key="time_range", + label="Time range", + position=0, + type=InvestigationParameterType.DATETIME_RANGE, + saved_value=requested_window, + ) + self.create_investigation_block_parameter(block=block, parameter=parameter) + previous = self.create_execution( + block, + input_snapshot={"parameters": {"time_range": original_window}}, + ) + block.update(result_execution=previous) + + snapshot, _ = build_block_execution_snapshot( + block=block, projects=[self.project], accessible_project_ids={self.project.id} + ) + + assert snapshot["parameterChanges"] == {"time_range": requested_window} + assert snapshot["context"][0]["queryContext"]["parameters"] == { + "time_range": original_window + } + assert snapshot["queryContext"]["parameters"] == {"time_range": requested_window} + def test_relative_filters_keep_the_same_request_fingerprint(self) -> None: self.investigation.update(filters={"statsPeriod": "6d"}) block = self.create_block() - started_at = timezone.now() + started_at = timezone.now() - timedelta(days=10) + previous = self.create_execution( + block, started_at=started_at, input_snapshot={"filters": {"statsPeriod": "6d"}} + ) + block.update(result_execution=previous) with mock.patch( - "sentry.investigations.services.executions.timezone.now", return_value=started_at + "sentry.investigations.services.executions.timezone.now", + return_value=started_at + timedelta(days=10), ): - _, first = build_block_execution_snapshot( + snapshot, first = build_block_execution_snapshot( block=block, projects=[self.project], accessible_project_ids={self.project.id} ) with mock.patch( "sentry.investigations.services.executions.timezone.now", - return_value=started_at + timedelta(minutes=1), + return_value=started_at + timedelta(days=20), ): - _, retry = build_block_execution_snapshot( + retry_snapshot, retry = build_block_execution_snapshot( block=block, projects=[self.project], accessible_project_ids={self.project.id} ) + assert snapshot["queryContext"]["filters"] == { + "start": (started_at - timedelta(days=6)).isoformat(), + "end": started_at.isoformat(), + } + assert retry_snapshot == snapshot assert first == retry + previous.refresh_from_db() + assert previous.input_snapshot == {"filters": {"statsPeriod": "6d"}} def test_rejects_duplicate_project_scope(self) -> None: block = self.create_block() diff --git a/tests/sentry/investigations/test_agent.py b/tests/sentry/investigations/test_agent.py index e0523469d5b7..5148fc476126 100644 --- a/tests/sentry/investigations/test_agent.py +++ b/tests/sentry/investigations/test_agent.py @@ -338,6 +338,33 @@ def test_start_run_categorizes_the_run_as_an_investigation(self) -> None: assert client.category_key == "investigation" assert client.category_value == str(self.investigation.id) + def test_start_run_passes_parameter_changes_separately_from_saved_settings(self) -> None: + saved_context = { + "currentBlock": True, + "queryContext": { + "parameters": {"environment": ["production"]}, + "filters": {"start": "2025-08-01T00:00:00Z", "end": "2025-08-07T00:00:00Z"}, + }, + } + self.execution.input_snapshot.update( + { + "parameters": {"environment": ["staging"]}, + "parameterChanges": {"environment": ["staging"]}, + "context": [saved_context], + } + ) + client = MagicMock() + + start_execution_run(self.execution, self.organization, self.user, client) + + prompt = client.start_run.call_args.args[0] + serialized_context = prompt.split("\n", 1)[1].split( + "\n", 1 + )[0] + context = json.loads(serialized_context) + assert context["parameterChanges"] == {"environment": ["staging"]} + assert context["notebookContext"] == [saved_context] + @patch("sentry.investigations.agent.record_execution_started") def test_start_run_records_execution_started(self, record_started: MagicMock) -> None: pending_execution = self.create_investigation_block_execution(