Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b0e23bb
feat(investigations): Add the hypothesis row for agentic runs
billyvg Sep 10, 2026
ab819b7
fix(investigations): Give hypothesis cards verdict-driven borders
billyvg Sep 10, 2026
6eac7f8
fix(investigations): Dash every hypothesis that is not the answer
billyvg Sep 10, 2026
605046d
ref(investigations): Stop exporting hypothesis internals
billyvg Sep 10, 2026
62da57d
style(investigations): Match the hypothesis card spec
billyvg Sep 10, 2026
c720365
feat(investigations): Name the states a hypothesis passes through
billyvg Sep 11, 2026
876d22f
fix(investigations): Lay out evidence rows against the prototype
billyvg Sep 11, 2026
a3ca10c
feat(investigations): Render the hypothesis row on the detail view
billyvg Sep 13, 2026
bd8cda0
fix(investigations): Say why launching an investigation is unavailable
billyvg Sep 14, 2026
51f4380
feat(investigations): Show what the Seer run is doing above the hypot…
billyvg Sep 14, 2026
a21fca3
update status colors
billyvg Sep 14, 2026
8163284
feat(investigations): Create agentic runs from both entry points
billyvg Sep 14, 2026
87ba476
Revert "feat(investigations): Create agentic runs from both entry poi…
billyvg Sep 14, 2026
487c54c
fix(investigations): Wrap long symbols in hypothesis card text
billyvg Sep 14, 2026
45de482
ref(investigations): Simplify the detail header and content layout
billyvg Sep 14, 2026
03cf76a
ref(investigations): Keep the detail view in a measured column
billyvg Sep 15, 2026
6365e30
fix(investigations): Survive a run that settles before a command lands
billyvg Sep 15, 2026
4f89dc3
ref(string): Extract the snake_case humanizer to utils/string
billyvg Sep 16, 2026
29dd50f
fix(investigations): Open an evidence step from anywhere on its row
billyvg Sep 16, 2026
0e0f63f
ref(investigations): Stop handing the tally separator to translators
billyvg Sep 16, 2026
2fe5f78
fix(investigations): Spread the props Text hands its render function
billyvg Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions static/app/utils/string/humanize.spec.tsx
Original file line number Diff line number Diff line change
@@ -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('');
});
});
14 changes: 14 additions & 0 deletions static/app/utils/string/humanize.tsx
Original file line number Diff line number Diff line change
@@ -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());
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {OrganizationFixture} from 'sentry-fixture/organization';

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

expect(await screen.findByText('Verifying…')).toBeInTheDocument();
expect(screen.getAllByText('Awaiting evidence').length).toBeGreaterThan(0);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import {
import type {
InvestigationDetail,
InvestigationExecutionDetail,
InvestigationHypothesis,
InvestigationListItem,
InvestigationOrchestration,
InvestigationOrchestrationCommand,
InvestigationTitleGeneration,
} from 'sentry/views/investigations/types';

Expand All @@ -33,6 +36,8 @@ type InvestigationFixtureApiProps = {
list?: InvestigationListItem[];
mode?: FixtureApiMode;
openMembership?: boolean;
/** Agentic run state, keyed by investigation id. */
orchestration?: Record<string, InvestigationOrchestration>;
pageLinks?: string;
titleGenerations?: Record<string, InvestigationTitleGeneration>;
};
Expand Down Expand Up @@ -69,6 +74,7 @@ export function InvestigationFixtureApi({
list = [],
mode = 'success',
openMembership = true,
orchestration = {},
pageLinks,
titleGenerations = {},
}: InvestigationFixtureApiProps) {
Expand All @@ -95,6 +101,7 @@ export function InvestigationFixtureApi({
list,
mode,
openMembership,
orchestration,
pageLinks,
titleGenerations,
})
Expand Down Expand Up @@ -213,6 +220,7 @@ type FixtureState = {
details: Map<string, InvestigationDetail>;
executions: Map<string, InvestigationExecutionDetail>;
list: InvestigationListItem[];
orchestration: Map<string, InvestigationOrchestration>;
titleGenerations: Map<string, InvestigationTitleGeneration>;
pageLinks?: string;
};
Expand All @@ -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]) => [
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading