diff --git a/static/app/utils/string/humanize.spec.tsx b/static/app/utils/string/humanize.spec.tsx
new file mode 100644
index 000000000000..5cc42e2cd7a5
--- /dev/null
+++ b/static/app/utils/string/humanize.spec.tsx
@@ -0,0 +1,21 @@
+import {humanize} from 'sentry/utils/string/humanize';
+
+describe('humanize', () => {
+ it('replaces underscores with spaces', () => {
+ expect(humanize('reauth_required')).toBe('Reauth required');
+ });
+
+ it('capitalizes the first letter', () => {
+ expect(humanize('stalled')).toBe('Stalled');
+ });
+
+ // Unlike `capitalize`, the rest of the value is left alone: an acronym the
+ // API sent in caps is more readable kept that way.
+ it('leaves the rest of the casing alone', () => {
+ expect(humanize('needs_HTTP_retry')).toBe('Needs HTTP retry');
+ });
+
+ it('handles an empty string', () => {
+ expect(humanize('')).toBe('');
+ });
+});
diff --git a/static/app/utils/string/humanize.tsx b/static/app/utils/string/humanize.tsx
new file mode 100644
index 000000000000..9eaced0aa564
--- /dev/null
+++ b/static/app/utils/string/humanize.tsx
@@ -0,0 +1,14 @@
+/**
+ * Turn a snake_case wire value into something readable: underscores become
+ * spaces, and the first letter is capitalized.
+ *
+ * Meant for values from an open set — an API can introduce one before the
+ * frontend knows its name — where showing the raw value imperfectly beats
+ * dropping it. A value the frontend does recognize should get a translated
+ * label instead; this is the fallback for the ones it does not.
+ *
+ * @example humanize('reauth_required') // 'Reauth required'
+ */
+export function humanize(value: string): string {
+ return value.replaceAll('_', ' ').replace(/^./, character => character.toUpperCase());
+}
diff --git a/static/app/views/investigations/__stories__/investigationFixtureApi.spec.tsx b/static/app/views/investigations/__stories__/investigationFixtureApi.spec.tsx
index 9fe7fbe9258b..e8a5900f3e68 100644
--- a/static/app/views/investigations/__stories__/investigationFixtureApi.spec.tsx
+++ b/static/app/views/investigations/__stories__/investigationFixtureApi.spec.tsx
@@ -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';
@@ -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'],
@@ -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 () => {
@@ -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(
+
+
+ ,
+ {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);
+ });
+ });
});
diff --git a/static/app/views/investigations/__stories__/investigationFixtureApi.tsx b/static/app/views/investigations/__stories__/investigationFixtureApi.tsx
index b81c78b42ca0..aa726e45d91b 100644
--- a/static/app/views/investigations/__stories__/investigationFixtureApi.tsx
+++ b/static/app/views/investigations/__stories__/investigationFixtureApi.tsx
@@ -18,7 +18,10 @@ import {
import type {
InvestigationDetail,
InvestigationExecutionDetail,
+ InvestigationHypothesis,
InvestigationListItem,
+ InvestigationOrchestration,
+ InvestigationOrchestrationCommand,
InvestigationTitleGeneration,
} from 'sentry/views/investigations/types';
@@ -33,6 +36,8 @@ type InvestigationFixtureApiProps = {
list?: InvestigationListItem[];
mode?: FixtureApiMode;
openMembership?: boolean;
+ /** Agentic run state, keyed by investigation id. */
+ orchestration?: Record;
pageLinks?: string;
titleGenerations?: Record;
};
@@ -69,6 +74,7 @@ export function InvestigationFixtureApi({
list = [],
mode = 'success',
openMembership = true,
+ orchestration = {},
pageLinks,
titleGenerations = {},
}: InvestigationFixtureApiProps) {
@@ -95,6 +101,7 @@ export function InvestigationFixtureApi({
list,
mode,
openMembership,
+ orchestration,
pageLinks,
titleGenerations,
})
@@ -213,6 +220,7 @@ type FixtureState = {
details: Map;
executions: Map;
list: InvestigationListItem[];
+ orchestration: Map;
titleGenerations: Map;
pageLinks?: string;
};
@@ -237,6 +245,12 @@ function createFixtureState(config: FixtureApiConfig): FixtureState {
]),
details,
list,
+ orchestration: new Map(
+ Object.entries(config.orchestration ?? {}).map(([key, run]) => [
+ key,
+ cloneFixture(run),
+ ])
+ ),
pageLinks: config.pageLinks,
executions: new Map(
Object.entries(config.executions ?? {}).map(([key, execution]) => [
@@ -350,6 +364,40 @@ function handleFixtureRequest(
return {body: cloneFixture(duplicate)};
}
+ if (parts[1] === 'orchestration' && parts.length === 2 && method === 'GET') {
+ return {body: cloneFixture(getFixtureOrchestration(state, investigationId))};
+ }
+
+ if (
+ parts[1] === 'orchestration' &&
+ parts[2] === 'commands' &&
+ parts.length === 3 &&
+ method === 'POST'
+ ) {
+ const run = getFixtureOrchestration(state, investigationId);
+ const command = data.command as InvestigationOrchestrationCommand;
+ // Seer would apply the command and push a new projection; the fixture
+ // applies it inline so a story stays interactive.
+ const updated: InvestigationOrchestration = {
+ ...run,
+ workflowVersion: run.workflowVersion + 1,
+ hypotheses: run.hypotheses.map(hypothesis =>
+ applyFixtureCommandToHypothesis(hypothesis, command)
+ ),
+ };
+ state.orchestration.set(investigationId, updated);
+ return {
+ body: {
+ accepted: true,
+ duplicate: false,
+ requestId: getDataString(data, 'requestId') ?? 'fixture-request',
+ runId: run.runId,
+ workflowVersion: updated.workflowVersion,
+ projection: cloneFixture(updated),
+ },
+ };
+ }
+
if (parts[1] === 'title-generation' && method === 'GET') {
const detail = getFixtureDetail(state, investigationId);
return {
@@ -552,6 +600,62 @@ function getFixtureDetail(state: FixtureState, investigationId: string) {
return generatedDetail;
}
+function getFixtureOrchestration(state: FixtureState, investigationId: string) {
+ const run = state.orchestration.get(investigationId);
+ if (!run) {
+ // The real endpoint 404s for an investigation with no agentic run behind
+ // it, so a story that forgot to supply one should say so loudly.
+ throw new Error(`No fixture orchestration run for investigation: ${investigationId}`);
+ }
+ return run;
+}
+
+function applyFixtureCommandToHypothesis(
+ hypothesis: InvestigationHypothesis,
+ command: InvestigationOrchestrationCommand
+): InvestigationHypothesis {
+ if (
+ command.type === 'set_hypothesis_disposition' &&
+ command.hypothesisId === hypothesis.id
+ ) {
+ if (command.disposition === null) {
+ // Clearing hands the hypothesis back to whatever the agent concluded.
+ return {
+ ...hypothesis,
+ decisionSource: 'agent',
+ effectiveStatus: hypothesis.agentVerdict?.verdict ?? 'inconclusive',
+ };
+ }
+ return {
+ ...hypothesis,
+ decisionSource: 'user',
+ effectiveStatus: command.disposition,
+ };
+ }
+
+ if (
+ command.type === 'retry' &&
+ command.target === 'hypothesis' &&
+ command.targetId === hypothesis.id
+ ) {
+ return {
+ ...hypothesis,
+ status: 'running',
+ effectiveStatus: 'investigating',
+ decisionSource: 'none',
+ confidence: null,
+ agentVerdict: null,
+ verificationSteps: (hypothesis.verificationSteps ?? []).map(step => ({
+ ...step,
+ status: 'queued',
+ result: null,
+ })),
+ };
+ }
+
+ return hypothesis;
+}
+
function setFixtureDetail(state: FixtureState, detail: InvestigationDetail) {
state.details.set(detail.id, detail);
const listIndex = state.list.findIndex(item => item.id === detail.id);
diff --git a/static/app/views/investigations/api.ts b/static/app/views/investigations/api.ts
index 8428f2e7697f..98525d8e46f5 100644
--- a/static/app/views/investigations/api.ts
+++ b/static/app/views/investigations/api.ts
@@ -11,10 +11,12 @@ import type {
InvestigationCandidate,
InvestigationBlock,
InvestigationBlockExecutionStart,
- InvestigationBlockKind,
InvestigationDetail,
InvestigationExecutionDetail,
InvestigationListItem,
+ InvestigationOrchestration,
+ InvestigationOrchestrationCommandResponse,
+ InvestigationOrchestrationCommandVariables,
InvestigationTitleGeneration,
MetricOpenPeriodInvestigationSource,
} from 'sentry/views/investigations/types';
@@ -97,6 +99,82 @@ export function investigationTitleGenerationQueryOptions(
);
}
+/**
+ * The live state of an agentic run: phase, broad scan, hypotheses, and report
+ * progress. Seer overwrites the whole projection on every orchestration event,
+ * so there is nothing to merge — the newest response wins outright.
+ *
+ * `staleTime: 0` because a running workflow changes constantly. Callers that
+ * render a run in progress should add a `refetchInterval` and drop it once
+ * `status` reaches a terminal value, as `InvestigationHypotheses` does.
+ */
+export function investigationOrchestrationQueryOptions(
+ organizationSlug: string,
+ investigationId: string
+) {
+ return apiOptions.as()(
+ '/organizations/$organizationIdOrSlug/investigations/$investigationId/orchestration/',
+ {
+ path: {
+ organizationIdOrSlug: organizationSlug,
+ investigationId,
+ },
+ staleTime: 0,
+ }
+ );
+}
+
+/**
+ * Send a viewer command — accept/reject a hypothesis, steer, retry, cancel — to
+ * a running workflow.
+ *
+ * The response carries the post-command projection, so it is written straight
+ * into the orchestration cache instead of triggering another fetch.
+ */
+export function useInvestigationOrchestrationCommandMutation(
+ organizationSlug: string,
+ investigationId: string,
+ options?: MutationOptions<
+ InvestigationOrchestrationCommandResponse,
+ InvestigationOrchestrationCommandVariables
+ >
+) {
+ const queryClient = useQueryClient();
+ const orchestrationOptions = investigationOrchestrationQueryOptions(
+ organizationSlug,
+ investigationId
+ );
+
+ return useMutation({
+ ...options,
+ mutationFn: ({command, expectedWorkflowVersion, requestId}) =>
+ fetchMutation({
+ url: getApiUrl(
+ '/organizations/$organizationIdOrSlug/investigations/$investigationId/orchestration/commands/',
+ {
+ path: {
+ organizationIdOrSlug: organizationSlug,
+ investigationId,
+ },
+ }
+ ),
+ method: 'POST',
+ data: {requestId, expectedWorkflowVersion, command},
+ }),
+ onSuccess: async (response, variables, onMutateResult, context) => {
+ queryClient.setQueryData(orchestrationOptions.queryKey, current =>
+ current ? {...current, json: response.projection} : current
+ );
+ await options?.onSuccess?.(response, variables, onMutateResult, context);
+ },
+ onError: async (error, variables, onMutateResult, context) => {
+ // A rejected command usually means the projection moved on beneath us.
+ await queryClient.invalidateQueries({queryKey: orchestrationOptions.queryKey});
+ await options?.onError?.(error, variables, onMutateResult, context);
+ },
+ });
+}
+
export function investigationCandidatesQueryOptions({
organizationSlug,
sources,
@@ -132,13 +210,6 @@ type FavoriteVariables = {
shouldFavorite: boolean;
};
-type AddBlockVariables = {
- investigation: InvestigationDetail;
- kind: InvestigationBlockKind;
- prompt: string;
- title: string;
-};
-
type RunBlockVariables = {
block: InvestigationBlock;
investigationVersion: number;
@@ -299,60 +370,6 @@ export function useRenameInvestigationMutation(
});
}
-export function useAddInvestigationBlockMutation(
- organizationSlug: string,
- investigationId: string,
- options?: MutationOptions
-) {
- const queryClient = useQueryClient();
- const detailOptions = getInvestigationDetailQueryOptions(
- organizationSlug,
- investigationId
- );
-
- return useMutation({
- ...options,
- mutationFn: ({investigation, kind, prompt, title}) =>
- fetchMutation({
- url: getApiUrl(
- '/organizations/$organizationIdOrSlug/investigations/$investigationId/blocks/',
- {
- path: {
- organizationIdOrSlug: organizationSlug,
- investigationId,
- },
- }
- ),
- method: 'POST',
- data: {
- investigationVersion: investigation.version,
- kind,
- title,
- generationPrompt: prompt,
- },
- }),
- onSuccess: async (block, variables, onMutateResult, context) => {
- queryClient.setQueryData(detailOptions.queryKey, current =>
- current
- ? {
- ...current,
- json: {
- ...current.json,
- blockCount: current.json.blockCount + 1,
- blocks: [...(current.json.blocks ?? []), block],
- version: current.json.version + 1,
- },
- }
- : current
- );
- await queryClient.invalidateQueries({
- queryKey: investigationListQueryOptions({organizationSlug}).queryKey,
- });
- await options?.onSuccess?.(block, variables, onMutateResult, context);
- },
- });
-}
-
export function useDeleteInvestigationBlockMutation(
organizationSlug: string,
investigationId: string,
diff --git a/static/app/views/investigations/detail/index.spec.tsx b/static/app/views/investigations/detail/index.spec.tsx
index 96548c469809..c8694507fc50 100644
--- a/static/app/views/investigations/detail/index.spec.tsx
+++ b/static/app/views/investigations/detail/index.spec.tsx
@@ -28,7 +28,11 @@ import {
investigationListQueryOptions,
} from 'sentry/views/investigations/api';
import InvestigationDetailView from 'sentry/views/investigations/detail';
-import {InvestigationDetailFixture} from 'sentry/views/investigations/fixtures';
+import {
+ InvestigationAgenticDetailFixture,
+ InvestigationDetailFixture,
+ InvestigationOrchestrationFixture,
+} from 'sentry/views/investigations/fixtures';
jest.unmock('@tanstack/react-pacer');
@@ -39,6 +43,8 @@ const organization = OrganizationFixture({
const detailUrl = '/organizations/org-slug/investigations/investigation-1/';
const titleGenerationUrl =
'/organizations/org-slug/investigations/investigation-1/title-generation/';
+const orchestrationUrl =
+ '/organizations/org-slug/investigations/investigation-1/orchestration/';
const feedbackForm = {
appendToDom: jest.fn(),
@@ -121,8 +127,6 @@ describe('Investigation detail', () => {
expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
expect(await screen.findByText('Investigate database latency')).toBeInTheDocument();
- expect(screen.getByText('Active')).toBeInTheDocument();
- expect(screen.queryByText('Completed')).not.toBeInTheDocument();
expect(screen.getByLabelText('Cell actions for Summary')).toBeInTheDocument();
expect(screen.getByLabelText('Cell actions for Latency query')).toBeInTheDocument();
expect(
@@ -982,106 +986,15 @@ describe('Investigation detail', () => {
).not.toBeInTheDocument();
});
- it('adds text and query cells and starts a never-run cell from its stored prompt', async () => {
- const fixture = InvestigationDetailFixture();
- const textTemplate = fixture.blocks[0];
- const queryTemplate = fixture.blocks[1];
- if (!textTemplate || !queryTemplate) {
- throw new Error('Expected text and query block fixtures.');
- }
- MockApiClient.addMockResponse({
- url: detailUrl,
- body: InvestigationDetailFixture({blocks: [], blockCount: 0}),
- });
- const blocksUrl = `${detailUrl}blocks/`;
- const textBlock = {
- ...textTemplate,
- id: 'text-block',
- title: 'Working theory',
- generationPrompt: 'Summarize the current evidence',
- };
- const textRequest = MockApiClient.addMockResponse({
- url: blocksUrl,
- method: 'POST',
- body: textBlock,
- });
-
- renderView();
- await userEvent.click(
- await screen.findByRole('button', {name: 'Add text cell (debug only)'})
- );
- await userEvent.type(screen.getByLabelText('Cell title'), 'Working theory');
- await userEvent.type(
- screen.getByLabelText('Cell instructions'),
- 'Summarize the current evidence'
- );
- await userEvent.click(screen.getByRole('button', {name: 'Add cell'}));
-
- await waitFor(() =>
- expect(textRequest).toHaveBeenCalledWith(
- blocksUrl,
- expect.objectContaining({
- data: {
- investigationVersion: 1,
- kind: 'text',
- title: 'Working theory',
- generationPrompt: 'Summarize the current evidence',
- },
- })
- )
- );
- expect(
- await screen.findByRole('button', {
- name: 'Cell actions for Working theory',
- })
- ).toBeInTheDocument();
- expect(screen.queryByDisplayValue('Working theory')).not.toBeInTheDocument();
-
- const queryBlock = {
- ...queryTemplate,
- id: 'query-block',
- title: 'Error volume',
- generationPrompt: 'Show errors over the last 24 hours',
- };
- const queryRequest = MockApiClient.addMockResponse({
- url: blocksUrl,
- method: 'POST',
- body: queryBlock,
- });
- await userEvent.click(
- screen.getByRole('button', {name: 'Add query cell (debug only)'})
- );
- await userEvent.type(screen.getByLabelText('Cell title'), 'Error volume');
- await userEvent.type(
- screen.getByLabelText('Cell instructions'),
- 'Show errors over the last 24 hours'
- );
- await userEvent.click(screen.getByRole('button', {name: 'Add cell'}));
-
- await waitFor(() =>
- expect(queryRequest).toHaveBeenCalledWith(
- blocksUrl,
- expect.objectContaining({
- data: {
- investigationVersion: 2,
- kind: 'query',
- title: 'Error volume',
- generationPrompt: 'Show errors over the last 24 hours',
- },
- })
- )
- );
- expect(
- (await screen.findAllByTestId('query-cell-title')).some(
- element => element.textContent === 'Error volume'
- )
- ).toBe(true);
-
- const updateUrl = `${blocksUrl}query-block/`;
+ it('starts a never-run cell from its stored prompt', async () => {
+ // `Latency query` arrives from the fixture never run, carrying the prompt
+ // it was created with — which is the state Refine is meant to pick up.
+ MockApiClient.addMockResponse({url: detailUrl, body: InvestigationDetailFixture()});
+ const updateUrl = `${detailUrl}blocks/block-2/`;
const updateRequest = MockApiClient.addMockResponse({
url: updateUrl,
method: 'PUT',
- body: queryBlock,
+ body: InvestigationDetailFixture().blocks[1],
});
const runUrl = `${updateUrl}executions/`;
const runRequest = MockApiClient.addMockResponse({
@@ -1101,10 +1014,12 @@ describe('Investigation detail', () => {
error: null,
},
});
- await chooseCellAction('Error volume', 'Refine');
- expect(screen.getByLabelText('Instructions for Seer')).toHaveValue(
- 'Show errors over the last 24 hours'
- );
+
+ renderView();
+ expect(await screen.findByText('Investigate database latency')).toBeInTheDocument();
+
+ await chooseCellAction('Latency query', 'Refine');
+ expect(screen.getByLabelText('Instructions for Seer')).toHaveValue('Find slow spans');
await userEvent.click(screen.getByRole('button', {name: 'Submit'}));
await waitFor(() =>
@@ -1112,9 +1027,9 @@ describe('Investigation detail', () => {
updateUrl,
expect.objectContaining({
data: {
- investigationVersion: 3,
+ investigationVersion: 1,
version: 1,
- generationPrompt: 'Show errors over the last 24 hours',
+ generationPrompt: 'Find slow spans',
},
})
)
@@ -1124,7 +1039,7 @@ describe('Investigation detail', () => {
expect(runRequest).toHaveBeenCalledWith(
runUrl,
expect.objectContaining({
- data: {investigationVersion: 3, version: 1},
+ data: {investigationVersion: 1, version: 1},
})
)
);
@@ -1854,4 +1769,47 @@ describe('Investigation detail', () => {
).toBeInTheDocument();
expect(request).not.toHaveBeenCalled();
});
+
+ // `orchestration` being present is the only thing that marks an investigation
+ // as agentic, and the orchestration endpoint 404s without a run, so the gate
+ // has to hold in both directions.
+ it('renders the hypothesis row for an agentic investigation', async () => {
+ MockApiClient.addMockResponse({
+ url: detailUrl,
+ body: InvestigationAgenticDetailFixture(),
+ });
+ const orchestrationRequest = MockApiClient.addMockResponse({
+ url: orchestrationUrl,
+ body: InvestigationOrchestrationFixture(),
+ });
+
+ renderView();
+
+ expect(await screen.findAllByTestId('investigation-hypothesis')).toHaveLength(3);
+ expect(
+ screen.getByRole('heading', {
+ name: 'Database or cache degradation delayed the response',
+ })
+ ).toBeInTheDocument();
+ expect(orchestrationRequest).toHaveBeenCalled();
+ });
+
+ it('does not reach for orchestration on a manual investigation', async () => {
+ MockApiClient.addMockResponse({
+ url: detailUrl,
+ body: InvestigationDetailFixture(),
+ });
+ const orchestrationRequest = MockApiClient.addMockResponse({
+ url: orchestrationUrl,
+ body: InvestigationOrchestrationFixture(),
+ });
+
+ renderView();
+
+ expect(
+ await screen.findByRole('textbox', {name: 'Investigation title'})
+ ).toBeInTheDocument();
+ expect(screen.queryByTestId('investigation-hypotheses')).not.toBeInTheDocument();
+ expect(orchestrationRequest).not.toHaveBeenCalled();
+ });
});
diff --git a/static/app/views/investigations/detail/index.tsx b/static/app/views/investigations/detail/index.tsx
index 0bfb5101841e..dd2a9170de8d 100644
--- a/static/app/views/investigations/detail/index.tsx
+++ b/static/app/views/investigations/detail/index.tsx
@@ -4,13 +4,10 @@ import {useDebouncer} from '@tanstack/react-pacer';
import {useQuery, useQueryClient} from '@tanstack/react-query';
import {Alert} from '@sentry/scraps/alert';
-import {Badge} from '@sentry/scraps/badge';
-import {Button} from '@sentry/scraps/button';
import {Input} from '@sentry/scraps/input';
import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout';
import {Link} from '@sentry/scraps/link';
-import {Heading, Text} from '@sentry/scraps/text';
-import {TextArea} from '@sentry/scraps/textarea';
+import {Text} from '@sentry/scraps/text';
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
import Feature from 'sentry/components/acl/feature';
@@ -22,7 +19,7 @@ import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton';
import * as Layout from 'sentry/components/layouts/thirds';
import {LoadingIndicator} from 'sentry/components/loadingIndicator';
import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle';
-import {IconAdd, IconSeer, IconStack} from 'sentry/icons';
+import {IconStack} from 'sentry/icons';
import {IconEllipsis} from 'sentry/icons/iconEllipsis';
import {t} from 'sentry/locale';
import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
@@ -34,7 +31,6 @@ import {
getInvestigationDetailQueryOptions,
investigationListQueryOptions,
investigationTitleGenerationQueryOptions,
- useAddInvestigationBlockMutation,
useDeleteInvestigationMutation,
useDuplicateInvestigationMutation,
useRenameInvestigationMutation,
@@ -44,12 +40,13 @@ import {
shouldDisplayInvestigationBlock,
shouldPollInvestigationBlocks,
} from 'sentry/views/investigations/detail/cell';
+import {
+ InvestigationHypotheses,
+ isInvestigationRunSettled,
+} from 'sentry/views/investigations/hypotheses/investigationHypotheses';
import {updateInvestigationCache} from 'sentry/views/investigations/investigationCache';
import {InvestigationSummaryCard} from 'sentry/views/investigations/investigationSummaryCard';
-import type {
- InvestigationBlockKind,
- InvestigationDetail,
-} from 'sentry/views/investigations/types';
+import type {InvestigationDetail} from 'sentry/views/investigations/types';
import {RouteError} from 'sentry/views/routeError';
const DEFAULT_INVESTIGATION_TITLE = 'Untitled investigation';
@@ -92,7 +89,14 @@ export function InvestigationBootstrapPage({investigationId}: {investigationId:
...detailOptions,
refetchInterval: query => {
const data = query.state.data?.json;
- return shouldPollInvestigationBlocks(data?.blocks ?? []) ||
+ // A live agentic run keeps this polling too: the notebook fills in as the
+ // agent writes blocks, and `orchestration` is what gates the hypothesis
+ // row, so a stale copy would leave the row hidden or showing a run that
+ // has since finished.
+ const orchestrationActive =
+ data?.orchestration && !isInvestigationRunSettled(data.orchestration.status);
+ return orchestrationActive ||
+ shouldPollInvestigationBlocks(data?.blocks ?? []) ||
isTitleGenerationActive(data?.titleGeneration?.status)
? 2000
: false;
@@ -214,12 +218,6 @@ function InvestigationPageContent({investigation}: {investigation: Investigation
},
onError: () => addErrorMessage(t('Unable to delete investigation.')),
});
- const addBlockMutation = useAddInvestigationBlockMutation(
- organization.slug,
- investigation.id,
- {onError: () => addErrorMessage(t('Unable to add cell.'))}
- );
-
function handleTitleChange(nextTitle: string) {
setDraftTitle(nextTitle);
updateInvestigationCache(
@@ -272,18 +270,6 @@ function InvestigationPageContent({investigation}: {investigation: Investigation
shouldDisplayInvestigationBlock(block, blocks)
);
- async function handleAddBlock({
- kind,
- prompt,
- title,
- }: {
- kind: InvestigationBlockKind;
- prompt: string;
- title: string;
- }) {
- await addBlockMutation.mutateAsync({investigation, kind, prompt, title});
- }
-
return (
@@ -368,8 +354,6 @@ function InvestigationPageContent({investigation}: {investigation: Investigation
{formatSourceType(investigation.sourceType)}
- {t('%s blocks', investigation.blockCount)}
-
{t('Last update: %s', formatNotebookDate(investigation.dateUpdated))}
@@ -393,10 +377,6 @@ function InvestigationPageContent({investigation}: {investigation: Investigation
>
{t('Give feedback')}
-
- {formatStatus(investigation.status)}
-
-
@@ -408,6 +388,18 @@ function InvestigationPageContent({investigation}: {investigation: Investigation
summaryDescription={investigation.summaryDescription}
/>
+ {/*
+ * Only an agentic investigation has hypotheses, and `orchestration`
+ * being present is the only thing that says one is: it is null for
+ * manual and template investigations, whose orchestration endpoint
+ * 404s.
+ */}
+ {investigation.orchestration ? (
+
+
+
+ ) : null}
+
{visibleSummaryBlock ? (
))}
- {investigation.status === 'active' ? (
-
- ) : null}
@@ -442,98 +428,6 @@ function InvestigationPageContent({investigation}: {investigation: Investigation
);
}
-function AddCellComposer({
- isAdding,
- onAdd,
-}: {
- isAdding: boolean;
- onAdd: (cell: {
- kind: InvestigationBlockKind;
- prompt: string;
- title: string;
- }) => Promise;
-}) {
- const [kind, setKind] = useState(null);
- const [title, setTitle] = useState('');
- const [prompt, setPrompt] = useState('');
-
- function reset() {
- setKind(null);
- setTitle('');
- setPrompt('');
- }
-
- async function handleAdd() {
- if (!kind || !prompt.trim()) {
- return;
- }
- try {
- await onAdd({kind, title: title.trim(), prompt: prompt.trim()});
- reset();
- } catch {
- // The mutation owns user-facing error handling and leaves the draft intact.
- }
- }
-
- if (!kind) {
- return (
-
- } onClick={() => setKind('text')}>
- {t('Add text cell (debug only)')}
-
- } onClick={() => setKind('query')}>
- {t('Add query cell (debug only)')}
-
-
- );
- }
-
- return (
-
-
-
- {kind === 'text'
- ? t('Add text cell (debug only)')
- : t('Add query cell (debug only)')}
-
- setTitle(event.target.value)}
- />
-
-
- );
-}
-
function isTitleGenerationActive(status: string | null | undefined) {
return status === 'pending' || status === 'running';
}
@@ -554,27 +448,10 @@ function formatSourceType(sourceType: string) {
return sourceType.replaceAll('_', ' ');
}
-function formatStatus(status: string) {
- if (status === 'active') {
- return t('Active');
- }
- return status.replaceAll('_', ' ').replace(/^./, character => character.toUpperCase());
-}
-
function formatNotebookDate(date: string) {
return new Date(date).toISOString().slice(0, 10).replaceAll('-', '.');
}
-function getStatusVariant(status: string): 'success' | 'warning' | 'muted' {
- if (status === 'completed' || status === 'active') {
- return 'success';
- }
- if (status === 'pending') {
- return 'warning';
- }
- return 'muted';
-}
-
const InvestigationCanvas = styled(Stack)`
width: min(100%, calc(884px + ${p => p.theme.space['2xl']}));
margin: 0 auto;
@@ -659,19 +536,6 @@ const MetaDivider = styled('span')`
border-left: 1px solid ${p => p.theme.tokens.border.primary};
`;
-const AddCellActions = styled(Flex)`
- padding: ${p => p.theme.space.xl} 0;
-`;
-
-const CellComposer = styled('section')`
- width: min(100%, 862px);
- margin: ${p => p.theme.space.lg} auto 0;
- padding: ${p => p.theme.space.xl};
- background: ${p => p.theme.tokens.background.secondary};
- border: 1px solid ${p => p.theme.tokens.border.primary};
- border-radius: ${p => p.theme.radius.md};
-`;
-
export default function InvestigationDetailView() {
const organization = useOrganization();
const {investigationId} = useParams<{investigationId: string}>();
diff --git a/static/app/views/investigations/fixtures/index.ts b/static/app/views/investigations/fixtures/index.ts
index 2473526ec986..4b6be2295011 100644
--- a/static/app/views/investigations/fixtures/index.ts
+++ b/static/app/views/investigations/fixtures/index.ts
@@ -2,10 +2,13 @@ import type {
InvestigationBlock,
InvestigationDetail,
InvestigationExecutionDetail,
+ InvestigationHypothesis,
InvestigationListItem,
+ InvestigationOrchestration,
InvestigationQueryOutput,
InvestigationTitleGeneration,
InvestigationTranscriptBlock,
+ InvestigationVerificationStep,
} from 'sentry/views/investigations/types';
export function InvestigationListItemFixture(
@@ -24,6 +27,9 @@ export function InvestigationListItemFixture(
isFavorited: false,
summary: null,
summaryDescription: null,
+ // Manual by default: an agentic investigation carries a summary here, and
+ // that is what gates the hypothesis row.
+ orchestration: null,
titleGeneration: {status: null},
...overrides,
};
@@ -122,12 +128,34 @@ export function InvestigationDetailFixture(
projectIds: [],
source: {type: 'manual', ref: {}, revision: null},
template: null,
+ // Manual by default: an agentic investigation carries a summary here, and
+ // that is what gates the hypothesis row.
+ orchestration: null,
titleGeneration: {status: null},
...detailOverrides,
blocks,
};
}
+/**
+ * An investigation with an agentic run behind it. `orchestration` being present
+ * is what tells the detail view to render the hypothesis row, so a fixture
+ * without it exercises the manual path instead.
+ */
+export function InvestigationAgenticDetailFixture(
+ overrides: Partial = {}
+): InvestigationDetail & {blocks: InvestigationBlock[]} {
+ return InvestigationDetailFixture({
+ orchestration: {
+ phase: 'investigating',
+ status: 'processing',
+ heartbeatAt: '2026-08-27T11:06:30Z',
+ notebookRevision: 5,
+ },
+ ...overrides,
+ });
+}
+
export function InvestigationTranscriptBlockFixture(
overrides: Partial = {}
): InvestigationTranscriptBlock {
@@ -393,3 +421,193 @@ export function InvestigationAwaitingInputExecutionFixture(
...overrides,
});
}
+
+export function InvestigationVerificationStepFixture(
+ overrides: Partial = {}
+): InvestigationVerificationStep {
+ return {
+ id: 'step-1',
+ order: 0,
+ title: 'Compare FCP with server response time',
+ objective: 'Establish whether the delay starts on the server or in the browser.',
+ method: 'Compare FCP and TTFB percentiles over the incident window.',
+ status: 'completed',
+ result: 'The delay begins before the document reaches the browser.',
+ evidence: [],
+ error: null,
+ ...overrides,
+ };
+}
+
+export function InvestigationHypothesisFixture(
+ overrides: Partial = {}
+): InvestigationHypothesis {
+ return {
+ id: 'hypothesis-1',
+ order: 0,
+ statement: 'Database or cache degradation delayed the response',
+ rationale:
+ 'FCP and TTFB rose together as cache misses exposed a much slower organization lookup.',
+ status: 'completed',
+ effectiveStatus: 'supported',
+ decisionSource: 'agent',
+ confidence: 0.86,
+ attempt: 0,
+ verificationSteps: [
+ InvestigationVerificationStepFixture(),
+ InvestigationVerificationStepFixture({
+ id: 'step-2',
+ order: 1,
+ title: 'Compare organization lookup spans',
+ objective: 'Isolate the slow span.',
+ method: 'Break lookup duration down by cache outcome.',
+ result: 'The lookup slowed sharply during the incident window.',
+ }),
+ InvestigationVerificationStepFixture({
+ id: 'step-3',
+ order: 2,
+ title: 'Inspect cache and Redis behavior',
+ objective: 'Confirm the cache is the source.',
+ method: 'Chart hit rate against response time.',
+ result: 'Cache misses increased at the same time as the slowdown.',
+ }),
+ ],
+ agentVerdict: {
+ verdict: 'supported',
+ confidence: 0.86,
+ rationale: 'Every check points at the same cache regression.',
+ supportingEvidenceIds: [],
+ refutingEvidenceIds: [],
+ remainingGaps: [],
+ },
+ evidence: [],
+ toolActivity: [],
+ error: null,
+ ...overrides,
+ };
+}
+
+/**
+ * The three-hypothesis shape the hypothesis row is designed around: one
+ * supported conclusion alongside a refuted and an inconclusive alternative.
+ */
+export function InvestigationHypothesesFixture(): InvestigationHypothesis[] {
+ return [
+ InvestigationHypothesisFixture(),
+ InvestigationHypothesisFixture({
+ id: 'hypothesis-2',
+ order: 1,
+ statement: 'An external SSO provider slowed the response',
+ rationale:
+ 'SSO and non-SSO organizations slowed together: provider spans stayed near baseline.',
+ effectiveStatus: 'refuted',
+ confidence: 0.91,
+ agentVerdict: {
+ verdict: 'refuted',
+ confidence: 0.91,
+ rationale: 'The shared delay contradicts an SSO-only explanation.',
+ supportingEvidenceIds: [],
+ refutingEvidenceIds: [],
+ remainingGaps: [],
+ },
+ verificationSteps: [
+ InvestigationVerificationStepFixture({
+ id: 'step-2-1',
+ order: 0,
+ title: 'Compare identity-provider spans',
+ objective: 'Check the provider call.',
+ method: 'Chart provider span duration over the window.',
+ result: 'No shared provider slowdown appears in the affected traces.',
+ }),
+ InvestigationVerificationStepFixture({
+ id: 'step-2-2',
+ order: 1,
+ title: 'Compare SSO and non-SSO organizations',
+ objective: 'Separate the two populations.',
+ method: 'Group response time by authentication method.',
+ result: 'Both groups show the same server-side delay.',
+ }),
+ ],
+ }),
+ InvestigationHypothesisFixture({
+ id: 'hypothesis-3',
+ order: 2,
+ statement: 'Session validation created a shared bottleneck',
+ rationale:
+ 'Available traces do not separate session-validation time from the cache and database delay.',
+ effectiveStatus: 'inconclusive',
+ confidence: 0.34,
+ agentVerdict: {
+ verdict: 'inconclusive',
+ confidence: 0.34,
+ rationale: 'Span coverage is too incomplete to isolate this contribution.',
+ supportingEvidenceIds: [],
+ refutingEvidenceIds: [],
+ remainingGaps: ['Session middleware spans are not instrumented.'],
+ },
+ verificationSteps: [
+ InvestigationVerificationStepFixture({
+ id: 'step-3-1',
+ order: 0,
+ title: 'Inspect session and middleware spans',
+ objective: 'Measure validation time.',
+ method: 'Break the request down by middleware span.',
+ result: 'Span coverage is incomplete in the affected trace sample.',
+ }),
+ InvestigationVerificationStepFixture({
+ id: 'step-3-2',
+ order: 1,
+ title: 'Check shared Redis pressure',
+ objective: 'Separate session load from cache load.',
+ method: 'Compare Redis command latency by key prefix.',
+ result:
+ 'Redis contention overlaps the slowdown but does not isolate session validation.',
+ }),
+ ],
+ }),
+ ];
+}
+
+export function InvestigationOrchestrationFixture(
+ overrides: Partial = {}
+): InvestigationOrchestration {
+ return {
+ runId: '9001',
+ investigationId: 'investigation-1',
+ workflowVersion: 4,
+ generation: 1,
+ notebookRevision: 5,
+ phase: 'reporting',
+ status: 'processing',
+ sourceType: 'breached_metric',
+ broadScan: {
+ status: 'completed',
+ summary: 'FCP regressed on organization login pages across every active release.',
+ toolActivity: [],
+ error: null,
+ },
+ hypotheses: InvestigationHypothesesFixture(),
+ report: {
+ status: 'composing',
+ revision: 2,
+ notebookRevision: 5,
+ currentBlockKey: null,
+ includedHypothesisIds: ['hypothesis-1', 'hypothesis-3'],
+ primaryHypothesisId: 'hypothesis-1',
+ error: null,
+ metadata: {
+ status: 'completed',
+ title: 'Why did FCP spike on organization login pages?',
+ summary: 'A cache regression slowed organization lookups',
+ summaryDescription:
+ 'Cache misses exposed a much slower organization lookup, delaying the server response.',
+ error: null,
+ },
+ },
+ pendingInput: null,
+ errors: [],
+ heartbeatAt: '2026-08-27T11:06:30Z',
+ updatedAt: '2026-08-27T11:06:30Z',
+ ...overrides,
+ };
+}
diff --git a/static/app/views/investigations/hypotheses/hypotheses.stories.tsx b/static/app/views/investigations/hypotheses/hypotheses.stories.tsx
new file mode 100644
index 000000000000..9aa6621ab5fc
--- /dev/null
+++ b/static/app/views/investigations/hypotheses/hypotheses.stories.tsx
@@ -0,0 +1,249 @@
+import {Fragment} from 'react';
+
+import * as Storybook from 'sentry/stories';
+import {InvestigationFixtureApi} from 'sentry/views/investigations/__stories__/investigationFixtureApi';
+import {
+ InvestigationDetailFixture,
+ InvestigationHypothesesFixture,
+ InvestigationHypothesisFixture,
+ InvestigationOrchestrationFixture,
+ InvestigationVerificationStepFixture,
+} from 'sentry/views/investigations/fixtures';
+import {HypothesisList} from 'sentry/views/investigations/hypotheses/hypothesisList';
+import {InvestigationHypotheses} from 'sentry/views/investigations/hypotheses/investigationHypotheses';
+
+export default Storybook.story('Investigations — Hypotheses', story => {
+ story('The hypothesis row', () => (
+
+
+ An agentic investigation proposes several explanations, tests each one, and lands
+ on a verdict. The row shows them side by side so the alternatives that were ruled
+ out stay visible next to the one that survived.
+
+
+ The data comes from projection.hypotheses on the orchestration
+ endpoint, and the lifted card is report.primaryHypothesisId.
+
+
+ The row is a grid of minmax(260px, 1fr) tracks with{' '}
+ auto-fit, so it drops columns whenever its own box stops fitting
+ another readable card — the viewport is never consulted. Drag the demo's edge to
+ watch it reflow.
+
+
+
+
+
+ ));
+
+ story('Statuses', () => (
+
+
+ A card renders effectiveStatus, which already folds the agent verdict
+ and any user disposition into the run status. Confidence only appears once the
+ agent has settled on a verdict.
+
+
+ The border carries the verdict three ways. A solid accent edge marks the
+ explanation that stands — supported by the evidence, or accepted by a person. A
+ dashed edge marks a card that was checked and is not the answer: ruled out,
+ inconclusive, failed and cancelled all read the same way to someone scanning the
+ row, so the status line carries that distinction rather than the border. A
+ hypothesis still being investigated keeps an ordinary solid edge, because dashing
+ it would announce a verdict the agent has not reached.
+
+
+
+
+
+ A hypothesis in flight is all one effectiveStatus, but it passes
+ through several states worth naming: formed, having its checks planned, running
+ them, and done checking but not yet judged. Those are read off the verification
+ steps, since that is the only place the distinction exists. Only the running state
+ is coloured, and it is the only one drawn as a spinning ring rather than a dot —
+ the rest are staging posts, not outcomes. All four keep a solid border: dashing
+ one would announce a verdict the agent has not reached. The heading over the steps
+ moves with them, from "Evidence to check" to "Evidence checked".
+
+
+
+
+
+ A failure is the one in-flight state that gets a colour, because it is the only
+ one that has stopped. The hypothesis says why, and so does each check that broke.
+
+
+
+
+
+ ));
+
+ story('Live, against a mocked orchestration API', () => (
+
+
+ InvestigationHypotheses reads the projection from{' '}
+ /investigations/$id/orchestration/ and posts decisions back to{' '}
+ /orchestration/commands/. Here both are served by{' '}
+ InvestigationFixtureApi, the in-memory fake the other investigations
+ stories use, so this is the real component and the real data flow with only the
+ network swapped out.
+
+
+ Accept or reject a hypothesis from its overflow menu: the fixture applies the
+ command, bumps workflowVersion, and returns the new projection, which
+ the mutation writes straight into the query cache. Accepting turns the border
+ accent; choosing the same decision twice clears it and hands the hypothesis back
+ to the agent's verdict.
+
+ Cards do not own commands. The surface rendering them decides which of accept,
+ reject, steer, and retry apply, and posts the chosen one to{' '}
+ /orchestration/commands/. Leave getActions off — as the
+ rows above do — and the overflow menu disappears, which is what a read-only
+ surface wants.
+
+
+ [
+ {key: 'accept', label: 'Accept', onAction: () => {}},
+ {key: 'reject', label: 'Reject', onAction: () => {}},
+ {
+ key: 'retry',
+ label: 'Investigate again',
+ disabled: hypothesis.effectiveStatus === 'investigating',
+ onAction: () => {},
+ },
+ ]}
+ />
+
+
+ ));
+});
+
+/** The four states a hypothesis passes through before it is judged. */
+function inFlightHypotheses() {
+ return [
+ InvestigationHypothesisFixture({
+ id: 'formed',
+ order: 0,
+ statement: 'A slow dependency upgrade changed request timing',
+ rationale: 'Nothing has been planned to test this yet.',
+ status: 'queued',
+ effectiveStatus: 'pending',
+ confidence: null,
+ agentVerdict: null,
+ verificationSteps: [],
+ }),
+ InvestigationHypothesisFixture({
+ id: 'preparing',
+ order: 1,
+ statement: 'A cache warm-up left the first requests cold',
+ rationale: 'The checks are planned but none has started.',
+ status: 'queued',
+ effectiveStatus: 'investigating',
+ confidence: null,
+ agentVerdict: null,
+ verificationSteps: [
+ InvestigationVerificationStepFixture({
+ id: 'preparing-step',
+ title: 'Compare cold and warm cache windows',
+ status: 'queued',
+ result: null,
+ }),
+ ],
+ }),
+ InvestigationHypothesisFixture({
+ id: 'checking',
+ order: 2,
+ statement: 'A noisy neighbour saturated the shared pool',
+ rationale: 'One check is running; the rest are queued behind it.',
+ status: 'running',
+ effectiveStatus: 'investigating',
+ confidence: null,
+ agentVerdict: null,
+ verificationSteps: [
+ InvestigationVerificationStepFixture({
+ id: 'checking-step',
+ title: 'Compare pool saturation across tenants',
+ status: 'running',
+ result: null,
+ }),
+ InvestigationVerificationStepFixture({
+ id: 'checking-queued',
+ order: 1,
+ title: 'Inspect connection wait time',
+ status: 'queued',
+ result: null,
+ }),
+ ],
+ }),
+ InvestigationHypothesisFixture({
+ id: 'checked',
+ order: 3,
+ statement: 'A retry storm amplified the original delay',
+ rationale: 'Every check has reported; the verdict has not landed yet.',
+ status: 'running',
+ effectiveStatus: 'investigating',
+ confidence: null,
+ agentVerdict: null,
+ verificationSteps: [
+ InvestigationVerificationStepFixture({
+ id: 'checked-step',
+ title: 'Compare retry volume with latency',
+ status: 'completed',
+ result: 'Retries tripled while the p95 climbed.',
+ }),
+ ],
+ }),
+ ];
+}
diff --git a/static/app/views/investigations/hypotheses/hypothesisCard.spec.tsx b/static/app/views/investigations/hypotheses/hypothesisCard.spec.tsx
new file mode 100644
index 000000000000..a08caabe2c2c
--- /dev/null
+++ b/static/app/views/investigations/hypotheses/hypothesisCard.spec.tsx
@@ -0,0 +1,322 @@
+import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary';
+
+import {
+ InvestigationHypothesisFixture,
+ InvestigationVerificationStepFixture,
+} from 'sentry/views/investigations/fixtures';
+import {HypothesisCard} from 'sentry/views/investigations/hypotheses/hypothesisCard';
+
+describe('HypothesisCard', () => {
+ it('renders the statement, rationale, and one-based ordinal', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByRole('heading', {name: 'An external SSO provider slowed the response'})
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText('SSO and non-SSO organizations slowed together.')
+ ).toBeInTheDocument();
+ // `order` is zero-based on the wire, so the second hypothesis reads as 2.
+ expect(screen.getByText('Hypothesis 2')).toBeInTheDocument();
+ });
+
+ it('shows confidence once the agent has reached a verdict', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Supported · 86% Confidence')).toBeInTheDocument();
+ });
+
+ it('falls back to the verdict confidence when the hypothesis omits it', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Inconclusive · 34% Confidence')).toBeInTheDocument();
+ });
+
+ it('omits confidence while the hypothesis is still in flight', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Verifying…')).toBeInTheDocument();
+ expect(screen.queryByText(/confidence/i)).not.toBeInTheDocument();
+ });
+
+ // A hypothesis in flight is one `effectiveStatus`, but it passes through
+ // several states worth naming. They are read off the verification steps,
+ // since that is the only place the distinction exists.
+ it.each([
+ ['no steps planned yet', 'Formed', [], 'queued'],
+ [
+ 'steps planned but not started',
+ 'Preparing checks',
+ [InvestigationVerificationStepFixture({status: 'queued', result: null})],
+ 'queued',
+ ],
+ [
+ 'steps running',
+ 'Verifying…',
+ [InvestigationVerificationStepFixture({status: 'running', result: null})],
+ 'running',
+ ],
+ [
+ 'every step finished, no verdict',
+ 'Evidence checked',
+ [InvestigationVerificationStepFixture({status: 'completed', result: 'Done.'})],
+ 'running',
+ ],
+ ] as const)('reads %s as "%s"', (_name, label, verificationSteps, status) => {
+ render(
+
+ );
+
+ // Scoped, because "Evidence checked" is also the heading over the steps.
+ expect(
+ within(screen.getByTestId('hypothesis-status')).getByText(label)
+ ).toBeInTheDocument();
+ });
+
+ it('lists verification steps in order with their results', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Evidence checked')).toBeInTheDocument();
+ // The only list inside a card is the evidence list; the card itself is an
+ // `li` belonging to the surrounding hypothesis row.
+ const steps = within(screen.getByRole('list')).getAllByRole('listitem');
+ expect(steps[0]).toHaveTextContent('First check');
+ expect(steps[1]).toHaveTextContent('Second check');
+ });
+
+ // The summary is the toggle's children rather than a label sitting beside a
+ // chevron-only button, so the whole row opens the step.
+ it('makes the whole row of a step that has run the toggle', async () => {
+ render(
+
+ );
+
+ const toggle = screen.getByRole('button', {
+ name: /Compare FCP with server response time/,
+ });
+ expect(toggle).toHaveTextContent(
+ 'The delay begins before the document reaches the browser.'
+ );
+ expect(screen.getByText('Establish where the delay starts.')).not.toBeVisible();
+
+ // Clicking the result line — the far side of the row from the chevron —
+ // still toggles, because it is inside the button.
+ await userEvent.click(
+ screen.getByText('The delay begins before the document reaches the browser.')
+ );
+
+ expect(screen.getByText('Establish where the delay starts.')).toBeVisible();
+ });
+
+ it('leaves a step with nothing to unpack unopenable', () => {
+ render(
+
+ );
+
+ expect(
+ screen.queryByRole('button', {name: /Compare FCP with server response time/})
+ ).not.toBeInTheDocument();
+ });
+
+ it('describes a step that has not produced a result yet', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Awaiting evidence')).toBeInTheDocument();
+ });
+
+ it("prefers a failed step's error message over the generic failure label", () => {
+ render(
+
+ );
+
+ expect(screen.getByText('The query timed out.')).toBeInTheDocument();
+ expect(screen.queryByText('This check failed.')).not.toBeInTheDocument();
+ });
+
+ it('surfaces a hypothesis-level error', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByText('No traces covered the incident window.')
+ ).toBeInTheDocument();
+ });
+
+ it.each([
+ // Only an explanation that stands gets the solid accent edge.
+ ['supported', 'accent'],
+ ['accepted', 'accent'],
+ // Checked, and not the answer. These read the same to someone scanning the
+ // row, so one broken edge covers all of them.
+ ['inconclusive', 'dashed'],
+ ['refuted', 'dashed'],
+ ['rejected', 'dashed'],
+ ['failed', 'dashed'],
+ ['cancelled', 'dashed'],
+ // Still being investigated. An ordinary edge, because dashing it would
+ // announce a verdict the agent has not reached.
+ ['investigating', 'solid'],
+ ['pending', 'solid'],
+ ] as const)('draws a %s hypothesis with a %s border', (effectiveStatus, border) => {
+ render(
+
+ );
+
+ expect(screen.getByTestId('investigation-hypothesis')).toHaveAttribute(
+ 'data-border',
+ border
+ );
+ });
+
+ it('hides the evidence section when there are no steps', () => {
+ render(
+
+ );
+
+ expect(screen.queryByText('Evidence checked')).not.toBeInTheDocument();
+ });
+
+ it('renders no overflow menu without actions', () => {
+ render();
+
+ expect(screen.queryByRole('button', {name: /Actions for/})).not.toBeInTheDocument();
+ });
+
+ it('opens the overflow menu and runs an action', async () => {
+ const onAction = jest.fn();
+ const hypothesis = InvestigationHypothesisFixture();
+ render(
+
+ );
+
+ await userEvent.click(
+ screen.getByRole('button', {name: `Actions for ${hypothesis.statement}`})
+ );
+ await userEvent.click(await screen.findByRole('menuitemradio', {name: 'Accept'}));
+
+ expect(onAction).toHaveBeenCalled();
+ });
+});
diff --git a/static/app/views/investigations/hypotheses/hypothesisCard.tsx b/static/app/views/investigations/hypotheses/hypothesisCard.tsx
new file mode 100644
index 000000000000..a808e39682e7
--- /dev/null
+++ b/static/app/views/investigations/hypotheses/hypothesisCard.tsx
@@ -0,0 +1,278 @@
+import styled from '@emotion/styled';
+
+import {Disclosure} from '@sentry/scraps/disclosure';
+import {Container, Flex, Stack} from '@sentry/scraps/layout';
+import {Heading, Text} from '@sentry/scraps/text';
+
+import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu';
+import {IconEllipsis} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {
+ getEvidenceSectionLabel,
+ getHypothesisCardBorder,
+ getVerificationStepStatusLabel,
+ HypothesisStatus,
+} from 'sentry/views/investigations/hypotheses/hypothesisStatus';
+import type {
+ InvestigationHypothesis,
+ InvestigationVerificationStep,
+} from 'sentry/views/investigations/types';
+
+type HypothesisCardProps = {
+ hypothesis: InvestigationHypothesis;
+ /**
+ * Menu items for the card's overflow menu. The card does not own commands —
+ * the surface rendering it decides which of accept, reject, steer, and retry
+ * apply, and supplies them here. No menu renders when this is empty.
+ */
+ actions?: MenuItemProps[];
+ /**
+ * Whether this is the hypothesis the report leads with
+ * (`report.primaryHypothesisId`). It lifts off the page so the conclusion is
+ * findable without reading every card. The border says where a hypothesis
+ * landed; this says which one the report is built around.
+ */
+ isPrimary?: boolean;
+};
+
+/**
+ * One hypothesis in an agentic investigation: what the agent proposed, where it
+ * landed, and the checks it ran to get there.
+ *
+ * The card is presentational and self-contained so it can appear in the
+ * investigation detail view, in a monitor alert drawer, or anywhere else a run
+ * is summarized. It sizes to its container rather than to the viewport.
+ */
+export function HypothesisCard({
+ actions,
+ hypothesis,
+ isPrimary = false,
+}: HypothesisCardProps) {
+ const steps = [...(hypothesis.verificationSteps ?? [])].sort(
+ (a, b) => a.order - b.order
+ );
+
+ return (
+
+
+
+
+ {/* `order` is zero-based in the projection; people count from one. */}
+ {t('Hypothesis %s', hypothesis.order + 1)}
+
+
+
+ {actions?.length ? (
+ ,
+ 'aria-label': t('Actions for %s', hypothesis.statement),
+ }}
+ items={actions}
+ />
+ ) : null}
+
+
+
+
+ {hypothesis.statement}
+
+ {hypothesis.rationale ? (
+
+ {hypothesis.rationale}
+
+ ) : null}
+
+
+ {hypothesis.error ? (
+
+ {hypothesis.error.message}
+
+ ) : null}
+
+ {steps.length > 0 ? (
+
+
+ {getEvidenceSectionLabel(steps)}
+
+
+ {steps.map(step => (
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
+
+function VerificationStepRow({step}: {step: InvestigationVerificationStep}) {
+ const failed = step.status === 'failed';
+ // A step's own error is more specific than the generic failure label, so it
+ // wins when both are present.
+ const detail =
+ step.result || step.error?.message || getVerificationStepStatusLabel(step.status);
+ // A step that has produced something can be opened for how the agent got
+ // there. One that has not is a bare row — there is no finding to unpack yet,
+ // and a chevron would promise one.
+ const hasRun = Boolean(step.result) || Boolean(step.error);
+ const summary = (
+ // A full flex-basis so the summary takes the row's spare width rather than
+ // splitting it with the chevron and wrapping in half the space it has.
+
+
+ {step.title}
+
+ {/*
+ * The agent writes these in terms of what it read, so a finding is mostly
+ * symbols: `module/file.py::function_name`, dotted paths, issue short IDs.
+ * None of them carry a break opportunity, and one long enough to outrun
+ * the column would otherwise push its own text out through the card edge
+ * rather than wrap inside it.
+ */}
+
+ {detail}
+
+
+ );
+
+ return (
+
+ {hasRun ? (
+
+ {/*
+ * The summary is the toggle's children, not `leadingItems`: the
+ * leading slot renders outside the button, which would leave the
+ * chevron alone as the click target on a row several hundred pixels
+ * wide. As children it sits inside the full-width stretched button,
+ * so the whole row opens the step — and it names the toggle without
+ * a separate aria-label.
+ */}
+ {summary}
+
+
+
+
+ {t('Objective')}
+
+
+ {step.objective}
+
+
+
+
+ {t('Method')}
+
+
+ {step.method}
+
+
+
+
+
+ ) : (
+ summary
+ )}
+
+ );
+}
+
+/**
+ * Lets the step's toggle hold the two-line summary that makes the whole row
+ * clickable.
+ *
+ * `Button` is sized as a single-line control — fixed height, `nowrap`, contents
+ * centred — which is right for a label and wrong for a block of title-plus-
+ * result that wraps. `&&` rather than a plain rule because these compete with
+ * the button's own class at equal specificity, and emotion's insertion order
+ * between the two is not something to rely on.
+ */
+const StepDisclosureTitle = styled(Disclosure.Title)`
+ && {
+ height: auto;
+ min-height: 0;
+ padding-block: ${p => p.theme.space.xs};
+ white-space: normal;
+ text-align: left;
+ }
+
+ /* Button wraps its contents in a span carrying the same single-line sizing. */
+ && > span {
+ width: 100%;
+ height: auto;
+ white-space: normal;
+ align-items: flex-start;
+ justify-content: flex-start;
+ }
+
+ /* The chevron belongs beside the title, not centred against a block whose
+ * height depends on how far the result wraps. */
+ && > span > :first-child {
+ margin-top: 1px;
+ }
+`;
+
+/**
+ * The card border carries the verdict, which is why it is CSS rather than the
+ * `border` prop: `getBorder` only ever emits `1px solid`, and a hypothesis that
+ * has not been established needs a broken edge. The colors still come from
+ * border tokens.
+ *
+ * Both variants are driven by data attributes because `Stack` forwards props it
+ * does not recognize to the DOM, where a bare `isPrimary` would land as an
+ * unknown attribute.
+ */
+const Card = styled(Stack)`
+ list-style: none;
+ border: 1px solid ${p => p.theme.tokens.border.primary};
+
+ /* The explanation that stands. */
+ &[data-border='accent'] {
+ border-color: ${p => p.theme.tokens.border.accent.vibrant};
+ }
+
+ /* Checked, and not the answer: ruled out, inconclusive, failed or cancelled.
+ * Dashed rather than dotted because a dotted hairline all but disappears at
+ * this border color. A hypothesis still being investigated keeps the solid
+ * default above — dashing it would announce a verdict nobody has reached. */
+ &[data-border='dashed'] {
+ border-style: dashed;
+ }
+
+ &[data-primary='true'] {
+ box-shadow: ${p => p.theme.shadow.low};
+ }
+`;
+
+// `ul` markers would otherwise sit in the card's padding next to each step.
+const EvidenceList = styled(Stack)`
+ list-style: none;
+`;
diff --git a/static/app/views/investigations/hypotheses/hypothesisList.spec.tsx b/static/app/views/investigations/hypotheses/hypothesisList.spec.tsx
new file mode 100644
index 000000000000..7da9aceff0a3
--- /dev/null
+++ b/static/app/views/investigations/hypotheses/hypothesisList.spec.tsx
@@ -0,0 +1,79 @@
+import {render, screen, within} from 'sentry-test/reactTestingLibrary';
+
+import {
+ InvestigationHypothesesFixture,
+ InvestigationHypothesisFixture,
+} from 'sentry/views/investigations/fixtures';
+import {HypothesisList} from 'sentry/views/investigations/hypotheses/hypothesisList';
+
+describe('HypothesisList', () => {
+ it('renders one card per hypothesis', () => {
+ render();
+
+ expect(screen.getAllByTestId('investigation-hypothesis')).toHaveLength(3);
+ expect(
+ screen.getByRole('heading', {
+ name: 'Database or cache degradation delayed the response',
+ })
+ ).toBeInTheDocument();
+ });
+
+ it('orders cards by the projection order, not array position', () => {
+ render(
+
+ );
+
+ const cards = within(screen.getByTestId('investigation-hypotheses')).getAllByTestId(
+ 'investigation-hypothesis'
+ );
+ expect(cards[0]).toHaveTextContent('First idea');
+ expect(cards[1]).toHaveTextContent('Third idea');
+ });
+
+ it('renders nothing when there are no hypotheses', () => {
+ render();
+
+ expect(screen.queryByTestId('investigation-hypotheses')).not.toBeInTheDocument();
+ });
+
+ it('marks the report primary hypothesis', () => {
+ render(
+
+ );
+
+ const cards = screen.getAllByTestId('investigation-hypothesis');
+ expect(cards[0]).toHaveAttribute('data-primary', 'false');
+ expect(cards[1]).toHaveAttribute('data-primary', 'true');
+ });
+
+ it('passes per-hypothesis actions to each card', async () => {
+ render(
+ [
+ {key: 'retry', label: `Retry ${hypothesis.id}`, onAction: jest.fn()},
+ ]}
+ />
+ );
+
+ expect(await screen.findAllByRole('button', {name: /Actions for/})).toHaveLength(3);
+ });
+});
diff --git a/static/app/views/investigations/hypotheses/hypothesisList.tsx b/static/app/views/investigations/hypotheses/hypothesisList.tsx
new file mode 100644
index 000000000000..f9bafa7b6ffd
--- /dev/null
+++ b/static/app/views/investigations/hypotheses/hypothesisList.tsx
@@ -0,0 +1,67 @@
+import {Grid} from '@sentry/scraps/layout';
+
+import type {MenuItemProps} from 'sentry/components/dropdownMenu';
+import {HypothesisCard} from 'sentry/views/investigations/hypotheses/hypothesisCard';
+import type {InvestigationHypothesis} from 'sentry/views/investigations/types';
+
+/**
+ * The narrowest a hypothesis card may get before the row drops to fewer
+ * columns. Below roughly this width the statement and its evidence rows stop
+ * being scannable.
+ */
+const MIN_CARD_WIDTH = '260px';
+
+type HypothesisListProps = {
+ hypotheses: InvestigationHypothesis[];
+ className?: string;
+ /**
+ * Builds the overflow menu for one hypothesis. Left out, cards render without
+ * a menu — which is what a read-only surface wants.
+ */
+ getActions?: (hypothesis: InvestigationHypothesis) => MenuItemProps[];
+ /** `report.primaryHypothesisId` from the projection, if the report has one. */
+ primaryHypothesisId?: string | null;
+};
+
+/**
+ * The hypotheses an agentic investigation is weighing, side by side.
+ *
+ * The row reflows on the *container's* width rather than the viewport's:
+ * `auto-fit` + `minmax` drops to fewer columns whenever the available space
+ * stops fitting another readable card. That is what lets the same component sit
+ * in a full-width detail view and in a narrow drawer without a breakpoint prop
+ * or a `containerType` on the parent.
+ */
+export function HypothesisList({
+ className,
+ getActions,
+ hypotheses,
+ primaryHypothesisId,
+}: HypothesisListProps) {
+ if (hypotheses.length === 0) {
+ return null;
+ }
+
+ const ordered = [...hypotheses].sort((a, b) => a.order - b.order);
+
+ return (
+
+ {ordered.map(hypothesis => (
+
+ ))}
+
+ );
+}
diff --git a/static/app/views/investigations/hypotheses/hypothesisStatus.stories.tsx b/static/app/views/investigations/hypotheses/hypothesisStatus.stories.tsx
new file mode 100644
index 000000000000..37c79011689d
--- /dev/null
+++ b/static/app/views/investigations/hypotheses/hypothesisStatus.stories.tsx
@@ -0,0 +1,265 @@
+import {Fragment} from 'react';
+
+import {Grid} from '@sentry/scraps/layout';
+import {Text} from '@sentry/scraps/text';
+
+import * as Storybook from 'sentry/stories';
+import {
+ InvestigationHypothesisFixture,
+ InvestigationVerificationStepFixture,
+} from 'sentry/views/investigations/fixtures';
+import {HypothesisStatus} from 'sentry/views/investigations/hypotheses/hypothesisStatus';
+import type {InvestigationHypothesis} from 'sentry/views/investigations/types';
+
+export default Storybook.story('Investigations — Hypothesis status', story => {
+ story('A verdict the agent reached', () => (
+
+
+ The status box is the dot-and-label line a hypothesis card leads with. It is not a
+ render of one field: the label comes from effectiveStatus,{' '}
+ decisionSource and the shape of verificationSteps{' '}
+ together. Every row below is the projection shape that actually produces that box.
+
+
+ Confidence is only meaningful once the agent has settled, so it is appended only
+ for those statuses. It is read from hypothesis.confidence, falling
+ back to agentVerdict.confidence for a projection that has filled in
+ only the latter.
+
+
+ Colour is deliberately sparing. supported and accepted{' '}
+ are the only greens. refuted and inconclusive are amber:
+ a hypothesis the agent tested and closed is not an error, but it is still a result
+ worth registering as you scan the row. rejected and{' '}
+ cancelled stay muted — nobody tested those — and of the settled
+ statuses only failed is dangerous.
+
+ A person accepting or rejecting a hypothesis lands in the same{' '}
+ effectiveStatus as the agent doing it, so{' '}
+ decisionSource: 'user' is the only thing separating them. The box
+ says which it was out loud, because whose call it was changes how the rest of the
+ report should be read.
+
+
+
+ ));
+
+ story('While the agent is still working', () => (
+
+
+ These four are all effectiveStatus: 'pending' or{' '}
+ 'investigating'. The distinction between them exists nowhere but the
+ verification steps, so the box reads it off them: no steps at all means nothing
+ has been planned yet, and steps that have every one produced something means only
+ the verdict is missing.
+
+
+ Only Verifying… is coloured, and it is the only one drawn as a spinning
+ ring rather than a dot — the agent is doing something, where the others are places
+ the hypothesis has come to a stop, however briefly. A column of moving indicators
+ would claim everything is live when nothing is.
+
+
+
+ ));
+
+ story('A status Sentry does not know yet', () => (
+
+
+ Hypothesis statuses are an open set: Seer can introduce one before this code knows
+ its name. Rather than drop it, an unrecognized value is humanized and rendered
+ muted, so a new status degrades to a readable label instead of an empty box.
+
+
+
+ ));
+});
+
+type StatusBoxRow = {
+ /** What in the projection puts the hypothesis into this state. */
+ caption: string;
+ hypothesis: InvestigationHypothesis;
+ key: string;
+};
+
+/** Each box beside the projection shape that produces it. */
+function StatusBoxes({rows}: {rows: StatusBoxRow[]}) {
+ return (
+
+
+ {rows.map(row => (
+
+
+
+ {row.caption}
+
+
+ ))}
+
+
+ );
+}
+
+const SETTLED: StatusBoxRow[] = [
+ {
+ key: 'supported',
+ caption: "effectiveStatus: 'supported' — the explanation that stands",
+ hypothesis: InvestigationHypothesisFixture(),
+ },
+ {
+ key: 'accepted',
+ caption: "effectiveStatus: 'accepted', decisionSource: 'agent'",
+ hypothesis: InvestigationHypothesisFixture({
+ effectiveStatus: 'accepted',
+ decisionSource: 'agent',
+ confidence: 0.92,
+ }),
+ },
+ {
+ key: 'refuted',
+ caption: "effectiveStatus: 'refuted' — tested and closed, so it reads amber",
+ hypothesis: InvestigationHypothesisFixture({
+ effectiveStatus: 'refuted',
+ confidence: 0.91,
+ }),
+ },
+ {
+ key: 'rejected',
+ caption: "effectiveStatus: 'rejected', decisionSource: 'agent'",
+ hypothesis: InvestigationHypothesisFixture({
+ effectiveStatus: 'rejected',
+ decisionSource: 'agent',
+ confidence: 0.77,
+ }),
+ },
+ {
+ key: 'inconclusive',
+ caption: "effectiveStatus: 'inconclusive' — checked, but nothing settled it",
+ hypothesis: InvestigationHypothesisFixture({
+ effectiveStatus: 'inconclusive',
+ confidence: 0.34,
+ }),
+ },
+ {
+ key: 'failed',
+ caption: "effectiveStatus: 'failed' — no confidence, because there is no verdict",
+ hypothesis: InvestigationHypothesisFixture({
+ status: 'failed',
+ effectiveStatus: 'failed',
+ confidence: null,
+ agentVerdict: null,
+ }),
+ },
+ {
+ key: 'cancelled',
+ caption: "effectiveStatus: 'cancelled' — stopped before it reached a verdict",
+ hypothesis: InvestigationHypothesisFixture({
+ status: 'cancelled',
+ effectiveStatus: 'cancelled',
+ confidence: null,
+ agentVerdict: null,
+ }),
+ },
+];
+
+const USER_DECISIONS: StatusBoxRow[] = [
+ {
+ key: 'accepted-by-you',
+ caption: "effectiveStatus: 'accepted', decisionSource: 'user'",
+ hypothesis: InvestigationHypothesisFixture({
+ effectiveStatus: 'accepted',
+ decisionSource: 'user',
+ confidence: null,
+ }),
+ },
+ {
+ key: 'rejected-by-you',
+ caption: "effectiveStatus: 'rejected', decisionSource: 'user'",
+ hypothesis: InvestigationHypothesisFixture({
+ effectiveStatus: 'rejected',
+ decisionSource: 'user',
+ confidence: null,
+ }),
+ },
+];
+
+const IN_FLIGHT: StatusBoxRow[] = [
+ {
+ key: 'formed',
+ caption: "effectiveStatus: 'pending', verificationSteps: [] — nothing planned yet",
+ hypothesis: InvestigationHypothesisFixture({
+ status: 'queued',
+ effectiveStatus: 'pending',
+ confidence: null,
+ agentVerdict: null,
+ verificationSteps: [],
+ }),
+ },
+ {
+ key: 'preparing',
+ caption: "status: 'queued' — the checks are planned, but none has started",
+ hypothesis: InvestigationHypothesisFixture({
+ status: 'queued',
+ effectiveStatus: 'investigating',
+ confidence: null,
+ agentVerdict: null,
+ verificationSteps: [
+ InvestigationVerificationStepFixture({status: 'queued', result: null}),
+ ],
+ }),
+ },
+ {
+ key: 'checking',
+ caption: "status: 'running' — live work, and the only box that spins",
+ hypothesis: InvestigationHypothesisFixture({
+ status: 'running',
+ effectiveStatus: 'investigating',
+ confidence: null,
+ agentVerdict: null,
+ verificationSteps: [
+ InvestigationVerificationStepFixture({status: 'running', result: null}),
+ InvestigationVerificationStepFixture({
+ id: 'step-2',
+ order: 1,
+ status: 'queued',
+ result: null,
+ }),
+ ],
+ }),
+ },
+ {
+ key: 'evidence-checked',
+ caption: 'every step has produced a result — only the verdict is missing',
+ hypothesis: InvestigationHypothesisFixture({
+ status: 'running',
+ effectiveStatus: 'investigating',
+ confidence: null,
+ agentVerdict: null,
+ verificationSteps: [
+ InvestigationVerificationStepFixture({
+ status: 'completed',
+ result: 'Retries tripled while the p95 climbed.',
+ }),
+ ],
+ }),
+ },
+];
+
+const UNKNOWN: StatusBoxRow[] = [
+ {
+ key: 'unknown',
+ caption: "effectiveStatus: 'needs_more_data' — humanized rather than dropped",
+ hypothesis: InvestigationHypothesisFixture({
+ effectiveStatus: 'needs_more_data',
+ confidence: null,
+ agentVerdict: null,
+ }),
+ },
+];
diff --git a/static/app/views/investigations/hypotheses/hypothesisStatus.tsx b/static/app/views/investigations/hypotheses/hypothesisStatus.tsx
new file mode 100644
index 000000000000..338ac471a3f1
--- /dev/null
+++ b/static/app/views/investigations/hypotheses/hypothesisStatus.tsx
@@ -0,0 +1,267 @@
+import styled from '@emotion/styled';
+
+import {Flex} from '@sentry/scraps/layout';
+import {StatusIndicator} from '@sentry/scraps/statusIndicator';
+import {Text} from '@sentry/scraps/text';
+
+import {LoadingIndicator} from 'sentry/components/loadingIndicator';
+import {t} from 'sentry/locale';
+import {humanize} from 'sentry/utils/string/humanize';
+import type {
+ InvestigationHypothesis,
+ InvestigationHypothesisStatus,
+ InvestigationOrchestrationWorkStatus,
+ InvestigationVerificationStep,
+} from 'sentry/views/investigations/types';
+
+type StatusVariant = 'success' | 'warning' | 'danger' | 'accent' | 'muted';
+
+/**
+ * Statuses where the agent has reached a verdict. Confidence is only meaningful
+ * once it has, so a hypothesis still in flight shows a bare label.
+ */
+const SETTLED_STATUSES = new Set([
+ 'supported',
+ 'refuted',
+ 'inconclusive',
+ 'accepted',
+ 'rejected',
+]);
+
+function isHypothesisSettled(status: InvestigationHypothesisStatus): boolean {
+ return SETTLED_STATUSES.has(status);
+}
+
+// Statuses are an open set — Seer can introduce one before Sentry knows the
+// name — so every lookup below falls back to `humanize` rather than dropping
+// the value.
+
+function hasRun(step: InvestigationVerificationStep): boolean {
+ return Boolean(step.result) || Boolean(step.error);
+}
+
+type HypothesisStatusDisplay = {
+ /** Whether the agent is actively working, which is what keeps the dot moving. */
+ inFlight: boolean;
+ label: string;
+ variant: StatusVariant;
+};
+
+/**
+ * What the status line says, and in what colour.
+ *
+ * A hypothesis in flight is all one `effectiveStatus`, but it passes through
+ * several states worth naming: formed, having its checks planned, running them,
+ * and done checking but not yet judged. Those are read off the verification
+ * steps, since that is the only place the distinction exists. Only the running
+ * state is coloured — the rest are staging posts, not outcomes.
+ */
+function getHypothesisStatusDisplay(
+ hypothesis: InvestigationHypothesis
+): HypothesisStatusDisplay {
+ const status = hypothesis.effectiveStatus;
+
+ // A decision the viewer made themselves reads differently from one the agent
+ // reached, even though both land in `effectiveStatus`.
+ if (hypothesis.decisionSource === 'user' && status === 'accepted') {
+ return {label: t('Accepted by you'), variant: 'success', inFlight: false};
+ }
+ if (hypothesis.decisionSource === 'user' && status === 'rejected') {
+ return {label: t('Rejected by you'), variant: 'muted', inFlight: false};
+ }
+
+ switch (status) {
+ case 'supported':
+ return {label: t('Supported'), variant: 'success', inFlight: false};
+ case 'accepted':
+ return {label: t('Accepted'), variant: 'success', inFlight: false};
+ // Ruled out by the evidence. Not an error — a hypothesis the agent tested
+ // and closed — but still a result worth registering as you scan the row,
+ // which is why it is warning rather than muted.
+ case 'refuted':
+ return {label: t('Refuted'), variant: 'warning', inFlight: false};
+ case 'rejected':
+ return {label: t('Rejected'), variant: 'muted', inFlight: false};
+ // Checked, but the evidence did not settle it either way.
+ case 'inconclusive':
+ return {label: t('Inconclusive'), variant: 'warning', inFlight: false};
+ case 'failed':
+ return {label: t('Failed'), variant: 'danger', inFlight: false};
+ case 'cancelled':
+ return {label: t('Cancelled'), variant: 'muted', inFlight: false};
+ case 'pending':
+ case 'investigating':
+ break;
+ default:
+ return {label: humanize(status), variant: 'muted', inFlight: false};
+ }
+
+ const steps = hypothesis.verificationSteps ?? [];
+ if (steps.length === 0) {
+ // Proposed, with nothing planned to test it yet.
+ return {label: t('Formed'), variant: 'muted', inFlight: false};
+ }
+ if (steps.every(hasRun)) {
+ // Every check has produced something; the verdict is what is missing.
+ return {label: t('Evidence checked'), variant: 'muted', inFlight: false};
+ }
+ if (hypothesis.status === 'running') {
+ return {label: t('Verifying…'), variant: 'accent', inFlight: true};
+ }
+ return {label: t('Preparing checks'), variant: 'muted', inFlight: false};
+}
+
+/**
+ * How a card's edge should be drawn for a given verdict.
+ *
+ * - `accent` — the explanation that stands: supported by the evidence, or
+ * endorsed by a person. A solid purple edge means "this is the answer".
+ * - `solid` — still being investigated. Nothing has been ruled out yet, so the
+ * card gets an ordinary edge; dashing it would announce a verdict the agent
+ * has not reached.
+ * - `dashed` — checked, and not the answer. Ruled out, inconclusive, failed and
+ * cancelled all read the same way to someone scanning the row, so one broken
+ * edge covers them and the status line carries the distinction.
+ */
+export function getHypothesisCardBorder(
+ status: InvestigationHypothesisStatus
+): 'accent' | 'solid' | 'dashed' {
+ if (status === 'supported' || status === 'accepted') {
+ return 'accent';
+ }
+ return status === 'pending' || status === 'investigating' ? 'solid' : 'dashed';
+}
+
+/** The heading above the steps, which depends on whether any have run yet. */
+export function getEvidenceSectionLabel(steps: InvestigationVerificationStep[]): string {
+ return steps.some(hasRun) ? t('Evidence checked') : t('Evidence to check');
+}
+
+/**
+ * What a verification step says about itself while it has no result yet. A step
+ * only carries a `result` once it has finished, so everything short of that
+ * needs a stand-in line rather than an empty row.
+ */
+export function getVerificationStepStatusLabel(
+ status: InvestigationOrchestrationWorkStatus
+): string {
+ switch (status) {
+ // Queued and running read the same from outside: the answer is not here
+ // yet. Only the states that need someone to act get their own line.
+ case 'not_started':
+ case 'queued':
+ case 'running':
+ return t('Awaiting evidence');
+ case 'blocked':
+ return t('Blocked on an earlier step.');
+ case 'reauth_required':
+ return t('Waiting on reauthentication.');
+ case 'stalled':
+ return t('Stalled.');
+ case 'cancelled':
+ return t('Cancelled before it finished.');
+ case 'failed':
+ return t('This check failed.');
+ case 'completed':
+ // A completed step with no result is a gap in the projection, not a state
+ // worth naming in the UI.
+ return t('No result was recorded.');
+ default:
+ return humanize(status);
+ }
+}
+
+/**
+ * The confidence the card should show, as a whole percentage, or null when
+ * there is nothing meaningful to show yet.
+ *
+ * The projection carries confidence in two places: denormalized onto the
+ * hypothesis, and on the agent's verdict. The hypothesis-level value is the one
+ * kept in step with `effectiveStatus`, so it wins; the verdict is the fallback
+ * for a projection that has only filled the latter in.
+ */
+function getHypothesisConfidencePercent(
+ hypothesis: InvestigationHypothesis
+): number | null {
+ if (!isHypothesisSettled(hypothesis.effectiveStatus)) {
+ return null;
+ }
+ const confidence = hypothesis.confidence ?? hypothesis.agentVerdict?.confidence;
+ if (typeof confidence !== 'number' || Number.isNaN(confidence)) {
+ return null;
+ }
+ return Math.round(confidence * 100);
+}
+
+type HypothesisStatusProps = {
+ hypothesis: InvestigationHypothesis;
+};
+
+/**
+ * The dot-and-label line above a hypothesis statement, e.g.
+ * "● Supported · 86% confidence".
+ */
+export function HypothesisStatus({hypothesis}: HypothesisStatusProps) {
+ const {inFlight, label, variant} = getHypothesisStatusDisplay(hypothesis);
+ const confidence = getHypothesisConfidencePercent(hypothesis);
+
+ return (
+ // The dot and its label are one statement, so they are one color.
+ //
+ // Left to themselves they disagree: `StatusIndicator` fills from the
+ // `background.*.vibrant` ramp while `Text` paints from `content.*`, which
+ // is darker in every variant — several steps for `muted`. Side by side the
+ // dot read as a lighter mark unrelated to the label it belongs to.
+ //
+ // `Text` already owns that variant-to-token mapping, `muted` ->
+ // `content.secondary` included, so its render-prop form hands the styling
+ // to the row itself rather than to a span inside it. The dot then picks the
+ // color up as `currentColor`, and there is no second copy of the table here
+ // to fall out of step with the design system.
+
+ {textProps => (
+ // Spread rather than picking `className` off: `Text` decides what its
+ // render function hands down, and naming one prop drops the rest.
+ //
+ // "Evidence checked" is both a status and the heading over the steps,
+ // so this needs to be addressable on its own.
+
+ {inFlight ? (
+ // Live work gets a ring rather than a dot: the agent is doing
+ // something, not resting in a state. Every other status is a place
+ // the hypothesis has come to a stop, however briefly.
+
+
+
+ ) : (
+
+
+
+ )}
+ {confidence === null
+ ? label
+ : // Translators: e.g. "Supported · 86% Confidence"
+ t('%s · %s%% Confidence', label, confidence)}
+
+ )}
+
+ );
+}
+
+/**
+ * Pins the dot to the line's color.
+ *
+ * `StatusIndicator` exposes no color of its own — the variant is the whole API
+ * — so this repaints the dot it draws in `::after`. The pulse behind it
+ * (`::before`) is deliberately left on its translucent token: it is a halo, and
+ * giving it the text color would make it a second, solid dot. `variant` is
+ * still passed through, so if this override ever stops matching, the dot falls
+ * back to its own ramp rather than disappearing.
+ */
+const StatusDot = styled('span')`
+ display: inline-flex;
+
+ & > span::after {
+ background-color: currentColor;
+ }
+`;
diff --git a/static/app/views/investigations/hypotheses/investigationHypotheses.spec.tsx b/static/app/views/investigations/hypotheses/investigationHypotheses.spec.tsx
new file mode 100644
index 000000000000..ba80c33259b4
--- /dev/null
+++ b/static/app/views/investigations/hypotheses/investigationHypotheses.spec.tsx
@@ -0,0 +1,278 @@
+import {QueryClientProvider} from '@tanstack/react-query';
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {makeTestQueryClient} from 'sentry-test/queryClient';
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import {InvestigationOrchestrationFixture} from 'sentry/views/investigations/fixtures';
+import {
+ InvestigationHypotheses,
+ isInvestigationRunSettled,
+} from 'sentry/views/investigations/hypotheses/investigationHypotheses';
+import type {InvestigationOrchestration} from 'sentry/views/investigations/types';
+
+const organization = OrganizationFixture({features: ['investigations']});
+const orchestrationUrl =
+ '/organizations/org-slug/investigations/investigation-1/orchestration/';
+const commandsUrl = `${orchestrationUrl}commands/`;
+
+function renderHypotheses() {
+ return render(, {
+ additionalWrapper: ({children}) => (
+ {children}
+ ),
+ organization,
+ });
+}
+
+describe('InvestigationHypotheses', () => {
+ it('renders the hypotheses carried on the projection', async () => {
+ MockApiClient.addMockResponse({
+ url: orchestrationUrl,
+ body: InvestigationOrchestrationFixture(),
+ });
+
+ renderHypotheses();
+
+ 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();
+ });
+
+ it('highlights the report primary hypothesis', async () => {
+ MockApiClient.addMockResponse({
+ url: orchestrationUrl,
+ body: InvestigationOrchestrationFixture(),
+ });
+
+ renderHypotheses();
+
+ const cards = await screen.findAllByTestId('investigation-hypothesis');
+ expect(cards[0]).toHaveAttribute('data-primary', 'true');
+ expect(cards[1]).toHaveAttribute('data-primary', 'false');
+ });
+
+ it('renders nothing when the projection carries no hypotheses', async () => {
+ const request = MockApiClient.addMockResponse({
+ url: orchestrationUrl,
+ body: InvestigationOrchestrationFixture({hypotheses: []}),
+ });
+
+ renderHypotheses();
+
+ await waitFor(() => expect(request).toHaveBeenCalled());
+ expect(screen.queryByTestId('investigation-hypotheses')).not.toBeInTheDocument();
+ });
+
+ it('does not fetch when there is no agentic run behind the investigation', () => {
+ const request = MockApiClient.addMockResponse({
+ url: orchestrationUrl,
+ body: InvestigationOrchestrationFixture(),
+ });
+
+ render(
+ ,
+ {
+ additionalWrapper: ({children}) => (
+
+ {children}
+
+ ),
+ organization,
+ }
+ );
+
+ expect(request).not.toHaveBeenCalled();
+ });
+
+ it('sends a disposition command fenced on the workflow version', async () => {
+ MockApiClient.addMockResponse({
+ url: orchestrationUrl,
+ body: InvestigationOrchestrationFixture({workflowVersion: 7}),
+ });
+ const commandRequest = MockApiClient.addMockResponse({
+ url: commandsUrl,
+ method: 'POST',
+ body: {
+ accepted: true,
+ duplicate: false,
+ requestId: 'request-1',
+ workflowVersion: 8,
+ commandStatus: 'accepted',
+ commandError: null,
+ runId: '9001',
+ projection: InvestigationOrchestrationFixture({workflowVersion: 8}),
+ },
+ });
+
+ renderHypotheses();
+
+ await userEvent.click(
+ await screen.findByRole('button', {
+ name: 'Actions for Database or cache degradation delayed the response',
+ })
+ );
+ await userEvent.click(await screen.findByRole('menuitemradio', {name: 'Accept'}));
+
+ await waitFor(() =>
+ expect(commandRequest).toHaveBeenCalledWith(
+ commandsUrl,
+ expect.objectContaining({
+ method: 'POST',
+ data: expect.objectContaining({
+ expectedWorkflowVersion: 7,
+ command: {
+ type: 'set_hypothesis_disposition',
+ hypothesisId: 'hypothesis-1',
+ disposition: 'accepted',
+ },
+ }),
+ })
+ )
+ );
+ });
+
+ it('writes the returned projection straight into the cache', async () => {
+ MockApiClient.addMockResponse({
+ url: orchestrationUrl,
+ body: InvestigationOrchestrationFixture({workflowVersion: 7}),
+ });
+ const updated: InvestigationOrchestration = InvestigationOrchestrationFixture({
+ workflowVersion: 8,
+ hypotheses: InvestigationOrchestrationFixture().hypotheses!.map(hypothesis =>
+ hypothesis.id === 'hypothesis-1'
+ ? {
+ ...hypothesis,
+ effectiveStatus: 'accepted' as const,
+ // A viewer decision, which the card credits to them by name.
+ decisionSource: 'user' as const,
+ }
+ : hypothesis
+ ),
+ });
+ MockApiClient.addMockResponse({
+ url: commandsUrl,
+ method: 'POST',
+ body: {
+ accepted: true,
+ duplicate: false,
+ requestId: 'request-1',
+ workflowVersion: 8,
+ commandStatus: 'accepted',
+ commandError: null,
+ runId: '9001',
+ projection: updated,
+ },
+ });
+
+ renderHypotheses();
+
+ await userEvent.click(
+ await screen.findByRole('button', {
+ name: 'Actions for Database or cache degradation delayed the response',
+ })
+ );
+ await userEvent.click(await screen.findByRole('menuitemradio', {name: 'Accept'}));
+
+ // A response that does carry the decision lands without a refetch. The
+ // server only does that once Seer has applied the command; see the settled
+ // run below for what happens in between.
+ expect(
+ await screen.findByText('Accepted by you · 86% Confidence')
+ ).toBeInTheDocument();
+ });
+
+ it('keeps re-reading a settled run until Seer applies an accepted command', async () => {
+ // A finished run polls no more, which is the point of settling it. But
+ // Sentry only queues a command: the response echoes the projection it
+ // already had with nothing but `workflowVersion` moved on, and Seer
+ // rewrites the real one later. Without the command reopening the polling,
+ // the card would sit on its old disposition until someone reloaded.
+ const orchestrationRequest = MockApiClient.addMockResponse({
+ url: orchestrationUrl,
+ body: InvestigationOrchestrationFixture({
+ status: 'completed',
+ workflowVersion: 7,
+ }),
+ });
+ const commandRequest = MockApiClient.addMockResponse({
+ url: commandsUrl,
+ method: 'POST',
+ body: {
+ accepted: true,
+ duplicate: false,
+ requestId: 'request-1',
+ workflowVersion: 8,
+ commandStatus: 'accepted',
+ commandError: null,
+ runId: '9001',
+ // Unchanged apart from the version, exactly as the endpoint returns it.
+ projection: InvestigationOrchestrationFixture({
+ status: 'completed',
+ workflowVersion: 8,
+ }),
+ },
+ });
+
+ renderHypotheses();
+ await screen.findAllByTestId('investigation-hypothesis');
+ const callsWhileSettled = orchestrationRequest.mock.calls.length;
+
+ await userEvent.click(
+ await screen.findByRole('button', {
+ name: 'Actions for Database or cache degradation delayed the response',
+ })
+ );
+ await userEvent.click(await screen.findByRole('menuitemradio', {name: 'Accept'}));
+ await waitFor(() => expect(commandRequest).toHaveBeenCalled());
+
+ // Nothing else would ask again: the run is completed, so this only grows
+ // because the command put the query back on its interval.
+ await waitFor(
+ () =>
+ expect(orchestrationRequest.mock.calls.length).toBeGreaterThan(callsWhileSettled),
+ {timeout: 6000}
+ );
+ }, 15_000);
+
+ it('renders hypotheses whose verification steps have not been planned yet', async () => {
+ // `verificationSteps` is `required=False` with no default on the contract,
+ // so a hypothesis the agent has only just formed arrives without the key at
+ // all — not as an empty list.
+ const hypotheses = InvestigationOrchestrationFixture().hypotheses.map(hypothesis => {
+ const unplanned = {...hypothesis, effectiveStatus: 'pending' as const};
+ delete unplanned.verificationSteps;
+ return unplanned;
+ });
+ MockApiClient.addMockResponse({
+ url: orchestrationUrl,
+ body: InvestigationOrchestrationFixture({hypotheses}),
+ });
+
+ renderHypotheses();
+
+ // Both the cards and the status block's tally read the steps, so rendering
+ // at all is the assertion: either one throws on a missing list.
+ expect(await screen.findAllByTestId('investigation-hypothesis')).toHaveLength(3);
+ expect(screen.getAllByText('Formed')).toHaveLength(3);
+ });
+});
+
+describe('isInvestigationRunSettled', () => {
+ it.each([
+ ['completed', true],
+ ['failed', true],
+ ['cancelled', true],
+ // Not terminal: the run resumes as soon as input arrives, possibly from
+ // another surface, so the projection has to keep being read.
+ ['awaiting_input', false],
+ ['processing', false],
+ ['pending', false],
+ ] as const)('reads %s as %s', (status, expected) => {
+ expect(isInvestigationRunSettled(status)).toBe(expected);
+ });
+});
diff --git a/static/app/views/investigations/hypotheses/investigationHypotheses.tsx b/static/app/views/investigations/hypotheses/investigationHypotheses.tsx
new file mode 100644
index 000000000000..a05d60c32d01
--- /dev/null
+++ b/static/app/views/investigations/hypotheses/investigationHypotheses.tsx
@@ -0,0 +1,197 @@
+import {useState} from 'react';
+import {uuid4} from '@sentry/core';
+import {useQuery} from '@tanstack/react-query';
+
+import {Container, Stack} from '@sentry/scraps/layout';
+
+import type {MenuItemProps} from 'sentry/components/dropdownMenu';
+import {t} from 'sentry/locale';
+import {useOrganization} from 'sentry/utils/useOrganization';
+import {
+ investigationOrchestrationQueryOptions,
+ useInvestigationOrchestrationCommandMutation,
+} from 'sentry/views/investigations/api';
+import {HypothesisList} from 'sentry/views/investigations/hypotheses/hypothesisList';
+import {getSeerStatusBlock} from 'sentry/views/investigations/statusBlock/getSeerStatusBlock';
+import {SeerStatusBlock} from 'sentry/views/investigations/statusBlock/seerStatusBlock';
+import type {
+ InvestigationHypothesis,
+ InvestigationOrchestrationStatus,
+} from 'sentry/views/investigations/types';
+
+/** How often to re-read the projection while a workflow is still moving. */
+const POLL_INTERVAL_MS = 2000;
+
+/**
+ * How long to keep re-reading a settled run after a command was accepted.
+ *
+ * Sentry only queues a command: the response carries the *existing* projection
+ * with nothing but `workflowVersion` bumped, and Seer rewrites the projection
+ * when it actually applies the decision. On a run that has already finished
+ * polling is off, so without this the card would keep the old disposition until
+ * someone reloaded the page — and accepting or rejecting a hypothesis on a
+ * finished run is the main reason to touch that menu at all.
+ *
+ * Bounded rather than open-ended: if Seer never applies the command, this stops
+ * asking instead of polling a stopped run forever.
+ */
+const COMMAND_SETTLE_MS = 30_000;
+
+/**
+ * Whether a workflow has stopped moving on its own.
+ *
+ * `awaiting_input` is deliberately not terminal: the run resumes as soon as
+ * input arrives, which may happen from another surface, so polling has to
+ * continue. Exported because the detail view decides from the summary served
+ * alongside the investigation, and this component from the full projection —
+ * the same three statuses either way.
+ */
+export function isInvestigationRunSettled(
+ status: InvestigationOrchestrationStatus | undefined
+): boolean {
+ return status === 'completed' || status === 'failed' || status === 'cancelled';
+}
+
+type InvestigationHypothesesProps = {
+ investigationId: string;
+ /**
+ * Set false for an investigation with no agentic run behind it — the
+ * orchestration endpoint 404s for those, and there is nothing to poll.
+ */
+ enabled?: boolean;
+};
+
+/**
+ * The hypothesis row for an agentic investigation, wired to the live run.
+ *
+ * This is the whole agent-to-frontend path in one place. Seer overwrites the
+ * projection on every orchestration event; Sentry stores it on
+ * `InvestigationOrchestrationRun.projection` and serves the latest one here. So
+ * the agent decides what these cards say purely by what it writes into
+ * `projection.hypotheses` — there is no separate signal telling the frontend to
+ * render a hypothesis, and no block kind to add.
+ *
+ * Actions travel back the other way as versioned commands, fenced on
+ * `workflowVersion` so a decision made against a stale view is rejected rather
+ * than applied to a run that has moved on.
+ */
+export function InvestigationHypotheses({
+ enabled = true,
+ investigationId,
+}: InvestigationHypothesesProps) {
+ const organization = useOrganization();
+ // When the last accepted command was sent, or null if none has been. A
+ // command makes a settled run interesting again, because Seer is about to
+ // rewrite the projection behind it.
+ const [commandSentAt, setCommandSentAt] = useState(null);
+
+ const {data: projection} = useQuery({
+ ...investigationOrchestrationQueryOptions(organization.slug, investigationId),
+ enabled,
+ refetchInterval: query => {
+ if (!isInvestigationRunSettled(query.state.data?.json.status)) {
+ return POLL_INTERVAL_MS;
+ }
+ const waitingOnCommand =
+ commandSentAt !== null && Date.now() - commandSentAt < COMMAND_SETTLE_MS;
+ return waitingOnCommand ? POLL_INTERVAL_MS : false;
+ },
+ });
+
+ const commandMutation = useInvestigationOrchestrationCommandMutation(
+ organization.slug,
+ investigationId,
+ {onSuccess: () => setCommandSentAt(Date.now())}
+ );
+
+ // The status block is the run talking, so it appears as soon as there is a
+ // run — before the first hypothesis exists, which is exactly when a viewer
+ // most needs to be told that something is happening.
+ if (!projection) {
+ return null;
+ }
+
+ const statusBlock = getSeerStatusBlock(projection);
+ const {workflowVersion} = projection;
+ const commandPending = commandMutation.isPending;
+
+ function setDisposition(
+ hypothesis: InvestigationHypothesis,
+ disposition: 'accepted' | 'rejected'
+ ) {
+ commandMutation.mutate({
+ requestId: uuid4(),
+ expectedWorkflowVersion: workflowVersion,
+ command: {
+ type: 'set_hypothesis_disposition',
+ hypothesisId: hypothesis.id,
+ // Choosing the decision the viewer already made clears it, so the same
+ // menu entry toggles rather than needing a separate "undo".
+ disposition:
+ hypothesis.decisionSource === 'user' &&
+ hypothesis.effectiveStatus === disposition
+ ? null
+ : disposition,
+ },
+ });
+ }
+
+ function getActions(hypothesis: InvestigationHypothesis): MenuItemProps[] {
+ const decidedByUser = hypothesis.decisionSource === 'user';
+
+ return [
+ {
+ key: 'accept',
+ label:
+ decidedByUser && hypothesis.effectiveStatus === 'accepted'
+ ? t('Clear decision')
+ : t('Accept'),
+ disabled: commandPending,
+ onAction: () => setDisposition(hypothesis, 'accepted'),
+ },
+ {
+ key: 'reject',
+ label:
+ decidedByUser && hypothesis.effectiveStatus === 'rejected'
+ ? t('Clear decision')
+ : t('Reject'),
+ disabled: commandPending,
+ onAction: () => setDisposition(hypothesis, 'rejected'),
+ },
+ {
+ key: 'retry',
+ label: t('Investigate again'),
+ disabled: commandPending || hypothesis.effectiveStatus === 'investigating',
+ onAction: () =>
+ commandMutation.mutate({
+ requestId: uuid4(),
+ expectedWorkflowVersion: workflowVersion,
+ command: {type: 'retry', target: 'hypothesis', targetId: hypothesis.id},
+ }),
+ },
+ ];
+ }
+
+ // The status block and the hypotheses are one object on the page: the block
+ // says what the run is doing and the cards are what it is doing it to. The
+ // panel is what makes that legible — without it the block reads as a
+ // page-level banner that happens to sit above an unrelated row.
+ return (
+
+
+ {statusBlock ? : null}
+
+
+
+ );
+}
diff --git a/static/app/views/investigations/statusBlock/getSeerStatusBlock.tsx b/static/app/views/investigations/statusBlock/getSeerStatusBlock.tsx
new file mode 100644
index 000000000000..1464fb8979c1
--- /dev/null
+++ b/static/app/views/investigations/statusBlock/getSeerStatusBlock.tsx
@@ -0,0 +1,193 @@
+import {t, tn} from 'sentry/locale';
+import type {SeerStatusBlockVariant} from 'sentry/views/investigations/statusBlock/seerStatusBlock';
+import type {InvestigationOrchestration} from 'sentry/views/investigations/types';
+
+type SeerStatusBlockContent = {
+ statusLabel: string;
+ title: string;
+ variant: SeerStatusBlockVariant;
+ description?: string;
+ meta?: string;
+};
+
+/**
+ * How many checks have produced something across every hypothesis.
+ *
+ * A step counts as done once it has a result *or* an error — a check that broke
+ * still ran, and the tally is "how much work stands behind this", not "how much
+ * of it succeeded".
+ */
+function countCompletedChecks(projection: InvestigationOrchestration): number {
+ return projection.hypotheses.reduce(
+ (total, hypothesis) =>
+ total +
+ (hypothesis.verificationSteps ?? []).filter(step => step.result || step.error)
+ .length,
+ 0
+ );
+}
+
+/**
+ * The tally under a finished or finishing run: how many explanations were
+ * weighed, and how much checking stands behind them.
+ *
+ * Absent until there is something to count, which is why the early running
+ * states render without it rather than claiming "0 possible causes".
+ */
+function getMeta(projection: InvestigationOrchestration): string | undefined {
+ const causeCount = projection.hypotheses.length;
+ if (causeCount === 0) {
+ return undefined;
+ }
+ const checkCount = countCompletedChecks(projection);
+ // Each half is translated; the bullet between them is punctuation, not a
+ // string a translator has anything to do with.
+ return [
+ tn('%s possible cause', '%s possible causes', causeCount),
+ tn('%s check completed', '%s checks completed', checkCount),
+ ].join(' • ');
+}
+
+/**
+ * The first error worth showing. The run-level list is the more specific of the
+ * two — `report.error` is whatever stopped the write-up, which is only the
+ * reason the run failed if nothing earlier did.
+ */
+function getFailureMessage(projection: InvestigationOrchestration): string | undefined {
+ return projection.errors[0]?.message ?? projection.report.error?.message ?? undefined;
+}
+
+/**
+ * What the status block should say for a run that is still moving.
+ *
+ * The phase is the only thing that separates these: `status` is `processing`
+ * for all of them. They are all the same `running` variant — the agent is
+ * working and the viewer has nothing to do — so only the words change.
+ */
+function getRunningContent(
+ projection: InvestigationOrchestration
+): SeerStatusBlockContent {
+ const causeCount = projection.hypotheses.length;
+
+ switch (projection.phase) {
+ case 'intake':
+ case 'broad_scan':
+ return {
+ variant: 'running',
+ title: t('Seer is gathering context'),
+ description: t(
+ 'Comparing the signals around the problem to work out where to look. No input needed.'
+ ),
+ statusLabel: t('Running…'),
+ };
+ case 'planning':
+ return {
+ variant: 'running',
+ title: t('Seer is looking for likely causes'),
+ description: t(
+ 'Possible causes will appear here as Seer connects the evidence. No input needed.'
+ ),
+ statusLabel: t('Running…'),
+ };
+ case 'investigating':
+ case 'judging':
+ return {
+ variant: 'running',
+ // Before the hypotheses land there is no count to quote, and "found 0
+ // possible causes" is worse than not saying it.
+ title: causeCount
+ ? tn(
+ 'Seer found %s possible cause and is checking for evidence',
+ 'Seer found %s possible causes and is checking for evidence',
+ causeCount
+ )
+ : t('Seer is checking for evidence'),
+ description: t(
+ 'Seer is checking for evidence to validate each possible cause. No input needed.'
+ ),
+ statusLabel: t('Running…'),
+ };
+ // The hypotheses are settled and the write-up is being assembled. Still the
+ // running variant, but the chip says so — this is the part that ends with
+ // the page changing under the viewer.
+ case 'reporting':
+ case 'metadata':
+ return {
+ variant: 'running',
+ title: t('Seer is bringing the findings together'),
+ meta: getMeta(projection),
+ description: t(
+ 'Organizing the explanation, supporting evidence, and next steps. Your investigation will open automatically.'
+ ),
+ statusLabel: t('Finalizing…'),
+ };
+ default:
+ return {
+ variant: 'running',
+ title: t('Seer is investigating'),
+ statusLabel: t('Running…'),
+ };
+ }
+}
+
+/**
+ * The status block's content for a run, or `null` when there is nothing to say.
+ *
+ * `status` decides the variant and `phase` decides the words, which is why this
+ * reads both: every in-flight phase shares one `processing` status, and every
+ * stopped run shares the `completed`/`failed`/`cancelled` phases with its
+ * status. Taking the variant from the status keeps the colour tied to whether
+ * the viewer has to do anything.
+ */
+export function getSeerStatusBlock(
+ projection: InvestigationOrchestration
+): SeerStatusBlockContent | null {
+ switch (projection.status) {
+ // Stopped, but recoverably, and the only state that asks for something
+ // back. The agent's own prompt is far more specific than anything that
+ // could be written here, so it wins when present.
+ case 'awaiting_input':
+ return {
+ variant: 'awaitingInput',
+ title: t('Seer needs more information to continue'),
+ description:
+ projection.pendingInput?.prompt ||
+ t('Seer is waiting on input before it can carry on.'),
+ statusLabel: t('Awaiting input'),
+ };
+ case 'failed':
+ return {
+ variant: 'failed',
+ title: t("Seer couldn't finish this investigation"),
+ description:
+ getFailureMessage(projection) ??
+ t('Checks that had already finished are saved.'),
+ statusLabel: t('Failed'),
+ };
+ case 'cancelled':
+ return {
+ variant: 'cancelled',
+ title: t('This investigation was stopped'),
+ description: t('Checks that had already finished are saved.'),
+ statusLabel: t('Cancelled'),
+ };
+ case 'completed':
+ return {
+ variant: 'complete',
+ title: t('Your investigation is ready'),
+ meta: getMeta(projection),
+ description: t(
+ 'Findings, supporting evidence, and recommended next steps are ready.'
+ ),
+ statusLabel: t('Complete'),
+ };
+ case 'pending':
+ case 'processing':
+ return getRunningContent(projection);
+ default:
+ // An unrecognized status is still a run in progress as far as the viewer
+ // is concerned — Seer can add one before this code knows the name, and a
+ // missing block reads as "nothing is happening", which is worse.
+ return getRunningContent(projection);
+ }
+}
diff --git a/static/app/views/investigations/statusBlock/seerStatusBlock.spec.tsx b/static/app/views/investigations/statusBlock/seerStatusBlock.spec.tsx
new file mode 100644
index 000000000000..544b29c48494
--- /dev/null
+++ b/static/app/views/investigations/statusBlock/seerStatusBlock.spec.tsx
@@ -0,0 +1,169 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {
+ InvestigationHypothesisFixture,
+ InvestigationOrchestrationFixture,
+ InvestigationVerificationStepFixture,
+} from 'sentry/views/investigations/fixtures';
+import {getSeerStatusBlock} from 'sentry/views/investigations/statusBlock/getSeerStatusBlock';
+import {SeerStatusBlock} from 'sentry/views/investigations/statusBlock/seerStatusBlock';
+
+describe('SeerStatusBlock', () => {
+ it('renders the sentence, the chip, and the elapsed time', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Seer is looking for likely causes')).toBeInTheDocument();
+ expect(screen.getByText('Possible causes will appear here.')).toBeInTheDocument();
+ expect(screen.getByText('Running…')).toBeInTheDocument();
+ expect(screen.getByText('101.5s')).toBeInTheDocument();
+ });
+
+ it('omits the elapsed time when there is nothing to count from', () => {
+ render(
+
+ );
+
+ expect(screen.queryByText(/\ds$/)).not.toBeInTheDocument();
+ });
+
+ it('renders an action only when one is supplied', () => {
+ const {rerender} = render(
+
+ );
+
+ expect(screen.queryByTestId('seer-status-block-action')).not.toBeInTheDocument();
+
+ rerender(
+ Connect Datadog}
+ />
+ );
+
+ expect(screen.getByTestId('seer-status-block-action')).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Connect Datadog'})).toBeInTheDocument();
+ });
+});
+
+describe('getSeerStatusBlock', () => {
+ it.each([
+ ['awaiting_input', 'awaitingInput', 'Awaiting input'],
+ ['failed', 'failed', 'Failed'],
+ ['cancelled', 'cancelled', 'Cancelled'],
+ ['completed', 'complete', 'Complete'],
+ ] as const)(
+ 'maps the %s run status to the %s variant',
+ (status, variant, statusLabel) => {
+ const block = getSeerStatusBlock(
+ InvestigationOrchestrationFixture({status, errors: []})
+ );
+
+ expect(block).toMatchObject({variant, statusLabel});
+ }
+ );
+
+ // Every in-flight phase shares one `processing` status, so the phase is the
+ // only thing that can tell them apart.
+ it.each([
+ ['intake', 'Seer is gathering context', 'Running…'],
+ ['broad_scan', 'Seer is gathering context', 'Running…'],
+ ['planning', 'Seer is looking for likely causes', 'Running…'],
+ ['reporting', 'Seer is bringing the findings together', 'Finalizing…'],
+ ['metadata', 'Seer is bringing the findings together', 'Finalizing…'],
+ ] as const)('reads the %s phase as "%s"', (phase, title, statusLabel) => {
+ const block = getSeerStatusBlock(
+ InvestigationOrchestrationFixture({status: 'processing', phase})
+ );
+
+ expect(block).toMatchObject({variant: 'running', title, statusLabel});
+ });
+
+ it('counts the hypotheses it is checking', () => {
+ const block = getSeerStatusBlock(
+ InvestigationOrchestrationFixture({status: 'processing', phase: 'investigating'})
+ );
+
+ expect(block?.title).toBe(
+ 'Seer found 3 possible causes and is checking for evidence'
+ );
+ });
+
+ it('does not quote a count before any hypothesis has landed', () => {
+ const block = getSeerStatusBlock(
+ InvestigationOrchestrationFixture({
+ status: 'processing',
+ phase: 'investigating',
+ hypotheses: [],
+ })
+ );
+
+ expect(block?.title).toBe('Seer is checking for evidence');
+ });
+
+ // A check that broke still ran: the tally is how much work stands behind the
+ // report, not how much of it succeeded.
+ it('tallies checks that errored alongside those that produced a result', () => {
+ const block = getSeerStatusBlock(
+ InvestigationOrchestrationFixture({
+ status: 'completed',
+ hypotheses: [
+ InvestigationHypothesisFixture({
+ verificationSteps: [
+ InvestigationVerificationStepFixture({id: 'a', result: 'Found it.'}),
+ InvestigationVerificationStepFixture({
+ id: 'b',
+ result: null,
+ error: {code: 'timeout', message: 'Timed out.', retryable: true},
+ }),
+ InvestigationVerificationStepFixture({id: 'c', result: null, error: null}),
+ ],
+ }),
+ ],
+ })
+ );
+
+ expect(block?.meta).toBe('1 possible cause • 2 checks completed');
+ });
+
+ it('prefers the run error over the report error', () => {
+ const block = getSeerStatusBlock(
+ InvestigationOrchestrationFixture({
+ status: 'failed',
+ errors: [
+ {code: 'timeout', message: 'The trace request timed out.', retryable: true},
+ ],
+ })
+ );
+
+ expect(block?.description).toBe('The trace request timed out.');
+ });
+
+ // Seer can introduce a status before this code knows the name. A missing
+ // block would read as "nothing is happening", which is worse than a generic
+ // one.
+ it('still renders a block for an unrecognized status', () => {
+ const block = getSeerStatusBlock(
+ InvestigationOrchestrationFixture({status: 'regrouping', phase: 'planning'})
+ );
+
+ expect(block).toMatchObject({variant: 'running'});
+ });
+});
diff --git a/static/app/views/investigations/statusBlock/seerStatusBlock.stories.tsx b/static/app/views/investigations/statusBlock/seerStatusBlock.stories.tsx
new file mode 100644
index 000000000000..ab0aa4317e22
--- /dev/null
+++ b/static/app/views/investigations/statusBlock/seerStatusBlock.stories.tsx
@@ -0,0 +1,201 @@
+import {Fragment} from 'react';
+
+import {Button} from '@sentry/scraps/button';
+import {Flex, Stack} from '@sentry/scraps/layout';
+import {Text} from '@sentry/scraps/text';
+
+import {IconAdd} from 'sentry/icons';
+import * as Storybook from 'sentry/stories';
+import {SeerStatusBlock} from 'sentry/views/investigations/statusBlock/seerStatusBlock';
+
+export default Storybook.story('Investigations — Seer status block', story => {
+ story('While the agent is working', () => (
+
+
+ The status block is the line above the hypotheses that says what Seer is doing.
+ There is exactly one on the page, in a fixed spot, for the whole life of a run —
+ so a reader who has learned where to look for "what is happening" never has to
+ relearn it when the run changes state.
+
+
+ Every phase the agent moves through on its own — gathering context, forming
+ hypotheses, checking evidence, composing the report — is the same{' '}
+ running variant. They differ in what they say, not how they look,
+ which is why the sentence and the chip label are props rather than another
+ variant. Note that "Finalizing…" is a running block too.
+
+ awaitingInput is the only state that has stopped recoverably
+ , and the only one that renders an action. Every other state is the
+ agent's to advance, so giving them a button would imply the viewer is holding
+ things up when they are not.
+
+
+ It is also one of only two states that colour their title. A run that is simply
+ working, or has finished cleanly, leaves the sentence in the ordinary heading
+ colour and lets the chip carry the state — otherwise every block on the page
+ shouts and none of them reads as urgent.
+
+
+
+
+
+ Datadog
+
+ Redis resource pressure and database latency
+
+ Aug 27, 09:00–11:00 UTC
+
+
+ }>
+ Connect Datadog
+
+
+ }
+ />
+
+
+ ));
+
+ story('When it has stopped', () => (
+
+
+ A failure is the other state that colours its title, because it is the only one
+ where nothing further will happen without someone reading the sentence.
+
+
+ cancelled is not in the design. It is here because the projection can
+ report it and the block still has to render something: falling through to{' '}
+ failed would paint a decision someone deliberately made bright red,
+ so it gets the neutral treatment instead.
+
+
+
+
+
+
+
+
+ ));
+
+ story('When it is done', () => (
+
+
+ The finished block is the one people scroll back to, so it carries a tally: how
+ many explanations were weighed, and how many checks stand behind them. The{' '}
+ meta line only appears once there is something to count, which is why
+ it is absent from the early running states above.
+
+ elapsed is optional, and the wired block currently leaves it out. The
+ projection carries no run-level start time — startedAt exists only on
+ individual block executions — so there is nothing honest to count from yet. The
+ prop is here because the design calls for it and the story can show it; the
+ component will start receiving a real value when the projection grows one.
+
+
+ When it is supplied it renders monospace and tabular, so a ticking counter does
+ not shuffle the chip sideways on every update.
+
+ The chip and the clock hold the top-right corner and never wrap under the
+ sentence: they are the part a viewer glances at. The title wraps around them
+ instead. Drag the demo's edge to watch it.
+
+
+
+
+
+ ));
+});
diff --git a/static/app/views/investigations/statusBlock/seerStatusBlock.tsx b/static/app/views/investigations/statusBlock/seerStatusBlock.tsx
new file mode 100644
index 000000000000..a4872ec4cab9
--- /dev/null
+++ b/static/app/views/investigations/statusBlock/seerStatusBlock.tsx
@@ -0,0 +1,204 @@
+import type {ReactNode} from 'react';
+
+import {Tag} from '@sentry/scraps/badge';
+import {Container, Flex, Stack} from '@sentry/scraps/layout';
+import {Text} from '@sentry/scraps/text';
+
+import {LoadingIndicator} from 'sentry/components/loadingIndicator';
+import {
+ IconCircleCheckmark,
+ IconCircleDashed,
+ IconFatal,
+ IconWarning,
+} from 'sentry/icons';
+
+/**
+ * Where an agentic run has got to, as one line the viewer can read without
+ * opening anything.
+ *
+ * `running` covers every phase the agent moves through on its own — gathering
+ * context, forming hypotheses, checking evidence, composing the report. They
+ * differ in what they *say*, not in how they look, so they share one variant
+ * and the caller supplies the sentence.
+ *
+ * The other three have all stopped. `awaitingInput` has stopped recoverably and
+ * is the only one that asks for something back, which is why it is the only one
+ * that renders an action.
+ */
+export type SeerStatusBlockVariant =
+ | 'running'
+ | 'awaitingInput'
+ | 'failed'
+ | 'complete'
+ // Not one of the designed states. A cancelled run still has to render, and
+ // falling through to `failed` would report a decision someone made as an
+ // error, so it gets the neutral treatment instead of a red one.
+ | 'cancelled';
+
+/**
+ * Only a state that has stopped and needs attention colours its title. A run
+ * that is simply working, or has finished cleanly, leaves the sentence in the
+ * ordinary heading colour and lets the chip carry the state — otherwise every
+ * status block on the page shouts.
+ */
+const TITLE_VARIANT = {
+ running: undefined,
+ awaitingInput: 'warning',
+ failed: 'danger',
+ complete: undefined,
+ cancelled: 'muted',
+} as const;
+
+const TAG_VARIANT = {
+ // `info` is the accent-purple pill: `content.accent` on
+ // `background.transparent.accent.muted`, which is what the design names.
+ running: 'info',
+ awaitingInput: 'warning',
+ failed: 'danger',
+ complete: 'success',
+ cancelled: 'muted',
+} as const;
+
+function StatusIcon({variant}: {variant: SeerStatusBlockVariant}) {
+ switch (variant) {
+ case 'running':
+ // A ring rather than a pulsing dot: the run is doing something, not
+ // sitting in a state.
+ return ;
+ case 'awaitingInput':
+ return ;
+ case 'failed':
+ return ;
+ case 'complete':
+ return ;
+ // A stopped run that is nobody's problem. A dashed ring reads as "this one
+ // is not going anywhere" without claiming anything went wrong.
+ case 'cancelled':
+ return ;
+ default:
+ return null;
+ }
+}
+
+type SeerStatusBlockProps = {
+ /** The short pill on the right, e.g. "Running…", "Awaiting input". */
+ statusLabel: string;
+ /** The sentence the block leads with, in the agent's voice. */
+ title: string;
+ variant: SeerStatusBlockVariant;
+ /**
+ * What the viewer can do about it. Only `awaitingInput` should supply one —
+ * every other state is the agent's to advance, and an action would imply
+ * otherwise.
+ */
+ action?: ReactNode;
+ className?: string;
+ /** The paragraph under the title. */
+ description?: string;
+ /**
+ * How long the run has been going, already formatted (e.g. "101.5s"). Left
+ * out when there is nothing to measure from: the projection carries no
+ * run-level start time, so the wired block omits this rather than invent one.
+ */
+ elapsed?: string;
+ /**
+ * A tally between the title and the description, e.g.
+ * "4 possible causes · 9 checks completed". Only worth showing once there is
+ * something to count.
+ */
+ meta?: string;
+};
+
+/**
+ * The status line above an agentic investigation's hypotheses.
+ *
+ * One component covers the whole run lifecycle because the shape never changes
+ * — icon, sentence, chip, elapsed time — only the words and the colour do. That
+ * is deliberate: the block sits in a fixed spot at the top of the panel, and a
+ * reader who has learned where to look for "what is Seer doing" should not have
+ * to relearn it when the run changes state.
+ *
+ * It is presentational and knows nothing about the projection, so it can be
+ * driven from a story, a fixture, or the live run.
+ */
+export function SeerStatusBlock({
+ action,
+ className,
+ description,
+ elapsed,
+ meta,
+ statusLabel,
+ title,
+ variant,
+}: SeerStatusBlockProps) {
+ return (
+
+
+ {/*
+ * A fixed column so the title, the description and the action all line
+ * up on the same left edge regardless of which icon is showing. `16px`
+ * is the title's line height, which centres the icon against the first
+ * line rather than the block.
+ */}
+
+
+
+
+
+
+
+ {title}
+
+ {/*
+ * The chip and the clock never wrap under the sentence: they are
+ * the part a viewer glances at, so they hold the top-right corner
+ * and the title wraps around them instead.
+ */}
+
+ {statusLabel}
+ {elapsed ? (
+ // Monospace and tabular so a ticking counter does not shuffle
+ // the chip sideways on every update.
+
+ {elapsed}
+
+ ) : null}
+
+
+
+ {meta ? (
+
+ {meta}
+
+ ) : null}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {action ? (
+
+ {action}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/static/app/views/investigations/types.ts b/static/app/views/investigations/types.ts
index cae66a822266..3370b1e955d7 100644
--- a/static/app/views/investigations/types.ts
+++ b/static/app/views/investigations/types.ts
@@ -1,5 +1,20 @@
import type {ToolResult} from 'sentry/views/seerExplorer/types';
+/**
+ * The scalar head of an agentic run, served inline with an investigation so a
+ * caller can tell one apart without a second request.
+ *
+ * This is the only marker an investigation carries for being agentic: it is
+ * `null` on manual and template investigations, whose orchestration endpoint
+ * 404s. Anything that needs the full run state fetches the projection.
+ */
+type InvestigationOrchestrationSummary = {
+ heartbeatAt: string | null;
+ notebookRevision: number;
+ phase: InvestigationOrchestrationPhase;
+ status: InvestigationOrchestrationStatus;
+};
+
export type InvestigationListItem = {
blockCount: number;
createdBy: string | null;
@@ -13,6 +28,7 @@ export type InvestigationListItem = {
summaryDescription: string | null;
title: string;
version: number;
+ orchestration?: InvestigationOrchestrationSummary | null;
titleGeneration?: {
status: 'pending' | 'running' | 'completed' | 'failed' | null;
};
@@ -154,3 +170,289 @@ export type InvestigationCandidate =
| {status: 'investigate'}
| {status: 'unavailable'}
| {investigationId: string; status: 'view'};
+
+// The orchestration projection: the whole live state of an agentic run, which
+// Seer pushes as one JSON blob on every orchestration event and Sentry serves
+// from `/investigations/$investigationId/orchestration/`. The server contract
+// lives in `src/sentry/investigations/contracts.py` — keep these types in step
+// with it.
+//
+// Those serializers are deliberately relaxed: fields Sentry does not know about
+// pass straight through instead of failing validation, so Seer can ship a new
+// field ahead of a Sentry deploy. `InvestigationOrchestrationOpenString` encodes
+// the same tolerance for enum values — a status Sentry has never heard of still
+// typechecks, so a `switch` over one has to keep its default branch.
+
+/** A known set of string values that still accepts one Seer added later. */
+type InvestigationOrchestrationOpenString = T | (string & {});
+
+type InvestigationOrchestrationPhase = InvestigationOrchestrationOpenString<
+ | 'intake'
+ | 'broad_scan'
+ | 'planning'
+ | 'investigating'
+ | 'judging'
+ | 'reporting'
+ | 'metadata'
+ | 'completed'
+ | 'failed'
+ | 'cancelled'
+>;
+
+export type InvestigationOrchestrationStatus = InvestigationOrchestrationOpenString<
+ 'pending' | 'processing' | 'awaiting_input' | 'completed' | 'failed' | 'cancelled'
+>;
+
+/** Lifecycle of one unit of agent work. Mirrors `WORK_STATUSES`. */
+export type InvestigationOrchestrationWorkStatus = InvestigationOrchestrationOpenString<
+ | 'not_started'
+ | 'queued'
+ | 'running'
+ | 'blocked'
+ | 'reauth_required'
+ | 'stalled'
+ | 'completed'
+ | 'failed'
+ | 'cancelled'
+>;
+
+/**
+ * What the UI shows for a hypothesis. The server folds the agent's verdict and
+ * the user's disposition into the run status to produce this, so it — not
+ * `status` — is what a hypothesis card renders.
+ * Mirrors `EFFECTIVE_HYPOTHESIS_STATUSES`.
+ */
+export type InvestigationHypothesisStatus = InvestigationOrchestrationOpenString<
+ | 'pending'
+ | 'investigating'
+ | 'supported'
+ | 'refuted'
+ | 'inconclusive'
+ | 'accepted'
+ | 'rejected'
+ | 'failed'
+ | 'cancelled'
+>;
+
+type InvestigationOrchestrationError = {
+ code: string;
+ message: string;
+ retryable: boolean;
+ occurredAt?: string;
+ requestId?: string;
+ source?: string | null;
+};
+
+type InvestigationToolActivity = {
+ id: string;
+ kind: InvestigationOrchestrationOpenString<'api' | 'library' | 'step' | 'tool'>;
+ status: InvestigationOrchestrationOpenString<
+ 'queued' | 'running' | 'completed' | 'failed'
+ >;
+ title: string;
+};
+
+type InvestigationOrchestrationEvidence = {
+ data: Record;
+ id: string;
+ kind: InvestigationOrchestrationOpenString<
+ | 'issue'
+ | 'event'
+ | 'trace'
+ | 'profile'
+ | 'replay'
+ | 'query'
+ | 'chart'
+ | 'release'
+ | 'monitor'
+ | 'external'
+ | 'other'
+ >;
+ title: string;
+ reference?: string | null;
+ summary?: string | null;
+ url?: string | null;
+};
+
+/** One check the agent ran against a hypothesis — an "Evidence checked" row. */
+export type InvestigationVerificationStep = {
+ error: InvestigationOrchestrationError | null;
+ evidence: InvestigationOrchestrationEvidence[];
+ id: string;
+ method: string;
+ objective: string;
+ order: number;
+ result: string | null;
+ status: InvestigationOrchestrationWorkStatus;
+ title: string;
+};
+
+type InvestigationAgentVerdict = {
+ confidence: number;
+ rationale: string;
+ refutingEvidenceIds: string[];
+ remainingGaps: string[];
+ supportingEvidenceIds: string[];
+ verdict: InvestigationOrchestrationOpenString<'supported' | 'refuted' | 'inconclusive'>;
+};
+
+export type InvestigationHypothesis = {
+ confidence: number | null;
+ decisionSource: InvestigationOrchestrationOpenString<'none' | 'agent' | 'user'>;
+ effectiveStatus: InvestigationHypothesisStatus;
+ error: InvestigationOrchestrationError | null;
+ evidence: InvestigationOrchestrationEvidence[];
+ id: string;
+ order: number;
+ rationale: string;
+ statement: string;
+ status: InvestigationOrchestrationWorkStatus;
+ agentVerdict?: InvestigationAgentVerdict | null;
+ attempt?: number;
+ automaticRetryCount?: number;
+ heartbeatAt?: string | null;
+ investigatorRunId?: number | null;
+ toolActivity?: InvestigationToolActivity[];
+ /**
+ * Absent, not empty, until the agent has planned any checks: the contract
+ * declares this `required=False` with no default, so DRF omits the key
+ * entirely rather than sending `[]`.
+ */
+ verificationSteps?: InvestigationVerificationStep[];
+};
+
+type InvestigationOrchestrationReport = {
+ currentBlockKey: string | null;
+ error: InvestigationOrchestrationError | null;
+ includedHypothesisIds: string[];
+ metadata: {
+ error: InvestigationOrchestrationError | null;
+ status: InvestigationOrchestrationOpenString<
+ 'not_started' | 'generating' | 'completed' | 'failed'
+ >;
+ summary: string | null;
+ summaryDescription: string | null;
+ title: string | null;
+ };
+ notebookRevision: number;
+ primaryHypothesisId: string | null;
+ revision: number;
+ status: InvestigationOrchestrationOpenString<
+ | 'not_started'
+ | 'waiting'
+ | 'composing'
+ | 'completed'
+ | 'partial_failed'
+ | 'failed'
+ | 'cancelled'
+ >;
+ automaticRetryCount?: number;
+ currentBlockStatus?: InvestigationOrchestrationWorkStatus | null;
+ currentBlockToolActivity?: InvestigationToolActivity[];
+ heartbeatAt?: string | null;
+ suggestedHypotheses?: Array<{
+ statement: string;
+ rationale?: string | null;
+ }>;
+};
+
+export type InvestigationOrchestration = {
+ broadScan: {
+ error: InvestigationOrchestrationError | null;
+ status: InvestigationOrchestrationWorkStatus;
+ summary: string | null;
+ attempt?: number;
+ automaticRetryCount?: number;
+ heartbeatAt?: string | null;
+ runId?: number | string | null;
+ toolActivity?: InvestigationToolActivity[];
+ };
+ errors: InvestigationOrchestrationError[];
+ generation: number;
+ heartbeatAt: string | null;
+ hypotheses: InvestigationHypothesis[];
+ investigationId: string;
+ notebookRevision: number;
+ phase: InvestigationOrchestrationPhase;
+ report: InvestigationOrchestrationReport;
+ runId: string | null;
+ sourceType: InvestigationOrchestrationOpenString<'manual' | 'breached_metric'>;
+ status: InvestigationOrchestrationStatus;
+ updatedAt: string;
+ workflowVersion: number;
+ pendingInput?: {
+ missingFields: Array<'prompt' | 'time_range'>;
+ prompt: string;
+ } | null;
+ steeringIntents?: Array<{
+ createdAt: string;
+ id: string;
+ instruction: string;
+ requestId: string;
+ target: InvestigationOrchestrationOpenString<
+ 'workflow' | 'hypothesis' | 'report' | 'block'
+ >;
+ targetId: string | null;
+ }>;
+};
+
+/**
+ * A viewer-issued instruction to a running workflow. Mirrors
+ * `COMMAND_VALIDATORS`. The inner payload stays camelCase because the server
+ * only case-converts the envelope keys.
+ */
+export type InvestigationOrchestrationCommand =
+ | {
+ type: 'provide_input';
+ prompt?: string;
+ timeRange?: {end: string; start: string};
+ }
+ | {
+ statement: string;
+ type: 'add_hypothesis';
+ rationale?: string | null;
+ }
+ | {
+ disposition: 'accepted' | 'rejected' | null;
+ hypothesisId: string;
+ type: 'set_hypothesis_disposition';
+ }
+ | {
+ instruction: string;
+ target: 'workflow' | 'hypothesis' | 'report' | 'block';
+ type: 'steer';
+ targetId?: string | null;
+ }
+ | {
+ target: 'run' | 'hypothesis' | 'report';
+ type: 'retry';
+ targetId?: string | null;
+ }
+ | {
+ type: 'cancel';
+ reason?: string | null;
+ };
+
+export type InvestigationOrchestrationCommandVariables = {
+ command: InvestigationOrchestrationCommand;
+ /**
+ * The version the caller believes it is acting on. The server rejects the
+ * command with a 409 if the workflow has moved on, so pass the version from
+ * the projection this command was composed against.
+ */
+ expectedWorkflowVersion: number;
+ /**
+ * Idempotency key. A repeat of the same id is accepted and reported back as a
+ * duplicate rather than applied twice.
+ */
+ requestId: string;
+};
+
+export type InvestigationOrchestrationCommandResponse = {
+ accepted: boolean;
+ duplicate: boolean;
+ projection: InvestigationOrchestration;
+ requestId: string;
+ runId: string | null;
+ workflowVersion: number;
+};
diff --git a/static/app/views/issueDetails/sidebar/metricDetectorTriggeredSection.spec.tsx b/static/app/views/issueDetails/sidebar/metricDetectorTriggeredSection.spec.tsx
index fa90c0ff5ba7..b1f4f94edcd5 100644
--- a/static/app/views/issueDetails/sidebar/metricDetectorTriggeredSection.spec.tsx
+++ b/static/app/views/issueDetails/sidebar/metricDetectorTriggeredSection.spec.tsx
@@ -307,6 +307,59 @@ describe('MetricDetectorTriggeredSection', () => {
expect(router.location.pathname).toBe('/explore/investigations/4567/');
});
+ it('explains why launching is unavailable', async () => {
+ const organization = OrganizationFixture({
+ slug: 'org-slug',
+ features: ['investigations'],
+ });
+ MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/investigations/candidates/',
+ method: 'POST',
+ body: {items: [{status: 'unavailable'}]},
+ });
+
+ render(, {organization});
+
+ const button = await screen.findByRole('button', {name: 'Launch Investigation'});
+ expect(button).toBeDisabled();
+ await userEvent.hover(button);
+ // Deliberately vague: naming the cause would reveal whether an issue the
+ // viewer cannot access exists.
+ expect(
+ await screen.findByText(
+ 'Seer cannot investigate this issue. It may not be linked to an active monitor, or you may not have access.'
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('explains that an issue with no open period cannot be investigated', async () => {
+ const organization = OrganizationFixture({
+ slug: 'org-slug',
+ features: ['investigations'],
+ });
+ const candidatesMock = MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/investigations/candidates/',
+ method: 'POST',
+ body: {items: [{status: 'investigate'}]},
+ });
+ MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/open-periods/',
+ body: [],
+ });
+
+ render(, {organization});
+
+ const button = await screen.findByRole('button', {name: 'Launch Investigation'});
+ expect(button).toBeDisabled();
+ // Open periods are already on the page, so naming this one gives nothing away.
+ await userEvent.hover(button);
+ expect(
+ await screen.findByText('This issue has no open period to investigate.')
+ ).toBeInTheDocument();
+ // Without a source there is nothing to ask about.
+ expect(candidatesMock).not.toHaveBeenCalled();
+ });
+
it('uses the latest open period when the displayed event is not linked to one', async () => {
const organization = OrganizationFixture({
slug: 'org-slug',
diff --git a/static/app/views/issueDetails/sidebar/metricDetectorTriggeredSection.tsx b/static/app/views/issueDetails/sidebar/metricDetectorTriggeredSection.tsx
index 9e120746eefc..cf41d36dcd40 100644
--- a/static/app/views/issueDetails/sidebar/metricDetectorTriggeredSection.tsx
+++ b/static/app/views/issueDetails/sidebar/metricDetectorTriggeredSection.tsx
@@ -61,7 +61,10 @@ import {
} from 'sentry/views/investigations/api';
import {shouldPollInvestigationBlocks} from 'sentry/views/investigations/detail/cell';
import {InvestigationSummaryCard} from 'sentry/views/investigations/investigationSummaryCard';
-import type {MetricOpenPeriodInvestigationSource} from 'sentry/views/investigations/types';
+import type {
+ InvestigationCandidate,
+ MetricOpenPeriodInvestigationSource,
+} from 'sentry/views/investigations/types';
import {FoldSection} from 'sentry/views/issueDetails/foldSection';
import {AttributeComparisonSection} from './attributeComparisonSection';
@@ -566,6 +569,34 @@ const GroupListWrapper = styled('div')`
margin-top: ${p => p.theme.space.md};
`;
+/**
+ * Why the launch button is off, or undefined when it is available.
+ *
+ * Both queries have settled by the time the button renders — a pending one
+ * shows a placeholder instead — so this is never "not yet".
+ *
+ * `unavailable` covers several situations the server deliberately does not
+ * separate: an issue that cannot be investigated at all, an existing
+ * investigation in a project the viewer cannot see, and a viewer who may not
+ * create one. Saying which would reveal whether an issue the viewer has no
+ * access to exists, so that wording stays vague on purpose. A missing open
+ * period is safe to name: the page already lists them.
+ */
+function getLaunchDisabledReason(
+ source: MetricOpenPeriodInvestigationSource | null,
+ candidateStatus: InvestigationCandidate['status'] | undefined
+): string | undefined {
+ if (source === null) {
+ return t('This issue has no open period to investigate.');
+ }
+ if (candidateStatus === 'unavailable') {
+ return t(
+ 'Seer cannot investigate this issue. It may not be linked to an active monitor, or you may not have access.'
+ );
+ }
+ return undefined;
+}
+
function SeerInvestigationSection({
eventId,
groupId,
@@ -687,6 +718,8 @@ function SeerInvestigationSection({
)
: null;
+ const launchDisabledReason = getLaunchDisabledReason(source, candidate?.status);
+
return (
source && launchMutation.mutate(source)}
>
{t('Launch Investigation')}