diff --git a/src/sentry/investigations/agent.py b/src/sentry/investigations/agent.py
index db9e315819c3..3f7b577ed39c 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,
@@ -121,8 +124,25 @@ 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.
+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 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
write or save the result. tableMarkdown must be a complete Markdown table (or an empty table). When
@@ -158,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"),
}
@@ -876,7 +897,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..c25643b53e98 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,8 @@ 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}
+ 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
@@ -310,6 +344,31 @@ 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", {}),
+ "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,
+ "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 +377,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 +389,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 +402,12 @@ def build_block_execution_snapshot(
}
if dataset_hint is not None:
snapshot["datasetHint"] = dataset_hint
+ if block.kind == InvestigationBlockKind.QUERY:
+ snapshot["parameterChanges"] = parameter_changes
+ snapshot["queryContext"] = {
+ **query_context,
+ "parameters": {**query_context.get("parameters", {}), **parameter_changes},
+ }
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..f009fb03021a 100644
--- a/tests/sentry/investigations/services/test_executions.py
+++ b/tests/sentry/investigations/services/test_executions.py
@@ -189,6 +189,249 @@ 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"},
+ }
+ 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)
+
+ 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 is not None
+ 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}}
+ assert previous.result is not None
+ 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_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() - 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 + timedelta(days=10),
+ ):
+ 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(days=20),
+ ):
+ 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()
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..5148fc476126 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()
@@ -319,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(