Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b0e23bb
feat(investigations): Add the hypothesis row for agentic runs
billyvg Sep 10, 2026
ab819b7
fix(investigations): Give hypothesis cards verdict-driven borders
billyvg Sep 10, 2026
6eac7f8
fix(investigations): Dash every hypothesis that is not the answer
billyvg Sep 10, 2026
605046d
ref(investigations): Stop exporting hypothesis internals
billyvg Sep 10, 2026
62da57d
style(investigations): Match the hypothesis card spec
billyvg Sep 10, 2026
c720365
feat(investigations): Name the states a hypothesis passes through
billyvg Sep 11, 2026
876d22f
fix(investigations): Lay out evidence rows against the prototype
billyvg Sep 11, 2026
a3ca10c
feat(investigations): Render the hypothesis row on the detail view
billyvg Sep 13, 2026
bd8cda0
fix(investigations): Say why launching an investigation is unavailable
billyvg Sep 14, 2026
51f4380
feat(investigations): Show what the Seer run is doing above the hypot…
billyvg Sep 14, 2026
a21fca3
update status colors
billyvg Sep 14, 2026
8163284
feat(investigations): Create agentic runs from both entry points
billyvg Sep 14, 2026
87ba476
Revert "feat(investigations): Create agentic runs from both entry poi…
billyvg Sep 14, 2026
487c54c
fix(investigations): Wrap long symbols in hypothesis card text
billyvg Sep 14, 2026
45de482
ref(investigations): Simplify the detail header and content layout
billyvg Sep 14, 2026
03cf76a
ref(investigations): Keep the detail view in a measured column
billyvg Sep 15, 2026
6365e30
fix(investigations): Survive a run that settles before a command lands
billyvg Sep 15, 2026
4fad7c0
feat(investigations): Create agentic runs from both entry points
billyvg Sep 14, 2026
5a2c9ef
fix(investigations): Keep polling while a run is still being created
billyvg Sep 15, 2026
8a569e1
fix(investigations): Preserve query context through refinement
arslnb Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions src/sentry/investigations/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
59 changes: 56 additions & 3 deletions src/sentry/investigations/services/executions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filters default is the investigation's filters now, but reference_time is the previous execution's start. _upsert_report_block writes no filters, so refining a report query block pairs today's duration with yesterday's anchor. The resulting window looks absolute and exact, and no query used it.

Suggest omitting the window when previous_input has no filters, and letting source.timeRange / analysisWindow supply it.

via Claude Code

"parameters": previous_input.get("parameters", parameters),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parameters come from the previous execution here, but snapshot["parameters"] holds the new values, and the prompt gives queryContext precedence. A user who edits a parameter and re-runs then gets the old value. Is freezing the parameters intended, or only the window?

via Claude Code

}
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,
{
Expand All @@ -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))
Expand All @@ -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,
Expand All @@ -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)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {OrganizationFixture} from 'sentry-fixture/organization';

import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';

import {QUERY_API_CLIENT} from 'sentry/utils/queryClient';
import {InvestigationsPage} from 'sentry/views/investigations';
Expand All @@ -10,7 +10,9 @@ import {
InvestigationBlockFixture,
InvestigationDetailFixture,
InvestigationListItemFixture,
InvestigationOrchestrationFixture,
} from 'sentry/views/investigations/fixtures';
import {InvestigationHypotheses} from 'sentry/views/investigations/hypotheses/investigationHypotheses';

const organization = OrganizationFixture({
features: ['investigations'],
Expand Down Expand Up @@ -81,22 +83,22 @@ describe('InvestigationFixtureApi', () => {
investigation.title
);

await userEvent.click(
screen.getByRole('button', {name: 'Add query cell (debug only)'})
);
await userEvent.type(
screen.getByRole('textbox', {name: 'Cell title'}),
'Slow checkouts'
);
await userEvent.type(
screen.getByRole('textbox', {name: 'Cell instructions'}),
'Compare checkout p95 before and after the deploy.'
);
await userEvent.click(screen.getByRole('button', {name: 'Add cell'}));
// Renaming is the detail mutation the page drives itself. Blurring the
// field cancels the debounce and writes immediately, so this needs no timer.
const titleField = screen.getByRole('textbox', {name: 'Investigation title'});
await userEvent.clear(titleField);
await userEvent.type(titleField, 'Invoice PDF timeouts everywhere');
await userEvent.tab();

expect(
await screen.findByRole('button', {name: 'Toggle Slow checkouts'})
).toBeInTheDocument();
// Read back through the fixture API rather than the field: the input would
// show the new title from the optimistic cache update either way, so only a
// fresh fetch proves the fixture backend actually stored it.
await waitFor(async () => {
const stored = await QUERY_API_CLIENT.requestPromise(
`/organizations/storybook-investigation-detail-test/investigations/${investigation.id}/`
);
expect(stored.title).toBe('Invoice PDF timeouts everywhere');
});
});

it('keeps fixture IDs and block positions unique across mutations', async () => {
Expand Down Expand Up @@ -153,4 +155,102 @@ describe('InvestigationFixtureApi', () => {
expect(firstDuplicate.id).toBe('fixture-id-collisions-copy');
expect(secondDuplicate.id).toBe('fixture-id-collisions-copy-2');
});

// These mirror the "Live, against a mocked orchestration API" story. The
// stories route is where this UI gets reviewed, so a fixture that no longer
// satisfies the component leaves a broken page rather than a failing build —
// rendering the story's contents here is what catches that.
describe('orchestration', () => {
function renderStoryHypotheses(
run = InvestigationOrchestrationFixture(),
investigationId = 'investigation-1'
) {
return render(
<InvestigationFixtureApi
organizationSlug="hypotheses-story"
details={[InvestigationDetailFixture({id: investigationId, blocks: []})]}
orchestration={{[investigationId]: run}}
>
<InvestigationHypotheses investigationId={investigationId} />
</InvestigationFixtureApi>,
{organization}
);
}

it('serves the projection to the hypothesis row', async () => {
renderStoryHypotheses();

expect(await screen.findAllByTestId('investigation-hypothesis')).toHaveLength(3);
expect(
screen.getByRole('heading', {
name: 'Database or cache degradation delayed the response',
})
).toBeInTheDocument();
expect(screen.getByText('Supported · 86% Confidence')).toBeInTheDocument();
expect(
screen.getByText('The delay begins before the document reaches the browser.')
).toBeInTheDocument();
});

it('applies a disposition command and returns the new projection', async () => {
renderStoryHypotheses();

await userEvent.click(
await screen.findByRole('button', {
name: 'Actions for An external SSO provider slowed the response',
})
);
await userEvent.click(await screen.findByRole('menuitemradio', {name: 'Accept'}));

// The command response carries the updated projection, so the card
// changes without another read.
expect(
await screen.findByText('Accepted by you · 91% Confidence')
).toBeInTheDocument();
// Accepting settles the hypothesis, so its edge picks up the accent.
expect(screen.getAllByTestId('investigation-hypothesis')[1]).toHaveAttribute(
'data-border',
'accent'
);
});

it('clears a disposition back to the agent verdict', async () => {
renderStoryHypotheses();

const trigger = await screen.findByRole('button', {
name: 'Actions for An external SSO provider slowed the response',
});
await userEvent.click(trigger);
await userEvent.click(await screen.findByRole('menuitemradio', {name: 'Accept'}));
await screen.findByText('Accepted by you · 91% Confidence');

await userEvent.click(trigger);
await userEvent.click(
await screen.findByRole('menuitemradio', {name: 'Clear decision'})
);

expect(await screen.findByText('Refuted · 91% Confidence')).toBeInTheDocument();
// Back to the agent's verdict, so the edge breaks again.
expect(screen.getAllByTestId('investigation-hypothesis')[1]).toHaveAttribute(
'data-border',
'dashed'
);
});

it('puts a retried hypothesis back into investigation', async () => {
renderStoryHypotheses();

await userEvent.click(
await screen.findByRole('button', {
name: 'Actions for Session validation created a shared bottleneck',
})
);
await userEvent.click(
await screen.findByRole('menuitemradio', {name: 'Investigate again'})
);

expect(await screen.findByText('Verifying…')).toBeInTheDocument();
expect(screen.getAllByText('Awaiting evidence').length).toBeGreaterThan(0);
});
});
});
Loading
Loading