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 ( - - - - - ); - } - - return ( - - - - {kind === 'text' - ? t('Add text cell (debug only)') - : t('Add query cell (debug only)')} - - setTitle(event.target.value)} - /> -