From 389afb5d09c12d385b3d7353d3776011bfddb254 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Mon, 2 Nov 2026 16:12:40 -0500 Subject: [PATCH 1/8] feat(seer): Show out-of-credits failures Runs and PR creates that stopped because the org ran out of Seer credits looked like generic errors and offered a retry that fails the same way. Autofix now reads failure_reason=out_of_credits and pr_creation_error_code=out_of_credits and shows the out-of-credits CTA instead. The drawer cards and the chat autofix card keep what the step wrote before it stopped, including a partial diff, and drop their re-run and Try again buttons. The PR card and chat card stop offering a PR retry, and the inbox disables it with an explanation. The PR failure toast and tooltips use out-of-credits copy, and the chat swaps Seer's plain-text error block for the CTA. Fixes CW-2328 Co-Authored-By: Claude Opus 5.5 --- .../autofix/useExplorerAutofix.spec.tsx | 29 +++ .../events/autofix/useExplorerAutofix.tsx | 16 ++ .../autofix/v3/artifactLoadingDetails.tsx | 74 +++++-- .../events/autofix/v3/autofixCards.spec.tsx | 191 ++++++++++++++++++ .../autofix/v3/autofixPreviews.spec.tsx | 27 +++ .../events/autofix/v3/autofixPreviews.tsx | 12 +- .../autofix/v3/codeChangesCard.stories.tsx | 94 +++++++++ .../events/autofix/v3/codeChangesCard.tsx | 93 ++++++--- .../events/autofix/v3/feedbackList.spec.tsx | 78 +++++++ .../events/autofix/v3/feedbackList.tsx | 39 ++-- .../autofix/v3/pullRequestsCard.stories.tsx | 42 ++++ .../events/autofix/v3/pullRequestsCard.tsx | 16 ++ .../events/autofix/v3/rootCauseCard.tsx | 47 +++-- .../events/autofix/v3/solutionCard.tsx | 14 +- .../embeds/components/autofix.spec.tsx | 88 ++++++++ .../embeds/components/autofix.stories.tsx | 149 ++++++++++++-- .../markdown/embeds/components/autofix.tsx | 130 ++++++++++-- .../components/seer/outOfCredits/messages.tsx | 57 ++++++ .../issueList/pages/inbox/index.spec.tsx | 40 ++++ .../issuePreview/issuePreviewSeerActions.tsx | 13 +- .../components/chat/chatWindow.stories.tsx | 35 ++++ .../components/chat/responseGroup.tsx | 36 +++- .../components/prLinksBar.spec.tsx | 67 ++++++ .../components/prLinksBar.stories.tsx | 24 +++ .../seerExplorer/components/prLinksBar.tsx | 47 +++-- .../components/seerExplorerContent.spec.tsx | 94 +++++++++ .../components/seerExplorerContent.tsx | 8 +- .../hooks/useSeerExplorer.spec.tsx | 64 ++++++ .../seerExplorer/hooks/useSeerExplorer.tsx | 20 +- .../hooks/useSeerExplorerPolling.spec.tsx | 2 + static/app/views/seerExplorer/types.tsx | 31 ++- 31 files changed, 1535 insertions(+), 142 deletions(-) create mode 100644 static/app/components/seer/outOfCredits/messages.tsx diff --git a/static/app/components/events/autofix/useExplorerAutofix.spec.tsx b/static/app/components/events/autofix/useExplorerAutofix.spec.tsx index 580aa4e81656..38692dc54103 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.spec.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.spec.tsx @@ -18,6 +18,7 @@ import { isCodeChangesArtifact, isCodingAgentsArtifact, isLastStepPrIteration, + isOutOfCreditsSection, isPrIterationBlock, isPullRequestsArtifact, isRootCauseArtifact, @@ -640,6 +641,34 @@ describe('getOrderedAutofixSections', () => { expect(prSection.artifacts).toEqual([[prState]]); }); + it('marks only the section the run stopped in with its failure reason', () => { + const sections = getOrderedAutofixSections({ + ...makeState([ + makeBlock({message: {metadata: {step: 'root_cause'}}}), + makeBlock({message: {metadata: {step: 'solution'}}}), + ]), + status: 'error', + failure_reason: 'out_of_credits', + }); + + expect(sections.map(s => [s.status, s.failureReason])).toEqual([ + ['completed', undefined], + ['error', 'out_of_credits'], + ]); + expect(sections.map(isOutOfCreditsSection)).toEqual([false, true]); + }); + + it('does not treat other failures as out of credits', () => { + const [section] = getOrderedAutofixSections({ + ...makeState([makeBlock({message: {metadata: {step: 'root_cause'}}})]), + status: 'error', + failure_reason: 'timeout', + }); + + expect(section!.status).toBe('error'); + expect(isOutOfCreditsSection(section!)).toBe(false); + }); + it('marks the synthetic pull_request section as processing while a PR is creating', () => { const sections = getOrderedAutofixSections({ ...makeState([makeBlock({message: {metadata: {step: 'code_changes'}}})]), diff --git a/static/app/components/events/autofix/useExplorerAutofix.tsx b/static/app/components/events/autofix/useExplorerAutofix.tsx index 1dccd53b5109..e29a759a8c54 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.tsx @@ -25,6 +25,7 @@ import { type Artifact, type Block, type ExplorerCodingAgentState, + type ExplorerFailureReason, type ExplorerFilePatch, type RepoPRState, type SeerExplorerRunId, @@ -216,6 +217,7 @@ export interface ExplorerAutofixState { status: 'processing' | 'completed' | 'error' | 'awaiting_user_input'; updated_at: string; coding_agents?: Record; + failure_reason?: ExplorerFailureReason | null; feature_id?: string | null; pending_user_input?: { data: Record; @@ -415,6 +417,10 @@ export interface AutofixSection { blocks: Block[]; status: 'processing' | 'completed' | 'error'; step: string; + /** + * Why the run stopped, on the section it stopped in. + */ + failureReason?: ExplorerFailureReason | null; index?: number; } @@ -460,6 +466,7 @@ function buildSection( if (currentStep && runState?.status === 'error') { section.status = 'error'; + section.failureReason = runState.failure_reason; } else if ( (!currentStep && runState?.status !== 'processing') || isLastBlockOfSection(blocks[blocks.length - 1]) || @@ -672,6 +679,15 @@ export function isCodingAgentsSection(section: AutofixSection): boolean { return section.step === 'coding_agents'; } +/** + * The step stopped because the organization ran out of Seer credits. Running it + * again fails the same way until someone adds credits, and the blocks it wrote + * before stopping are still worth showing. + */ +export function isOutOfCreditsSection(section: AutofixSection): boolean { + return section.status === 'error' && section.failureReason === 'out_of_credits'; +} + export function isPrIterationPaused(runState: ExplorerAutofixState | null): boolean { return runState?.pr_iteration_paused === true; } diff --git a/static/app/components/events/autofix/v3/artifactLoadingDetails.tsx b/static/app/components/events/autofix/v3/artifactLoadingDetails.tsx index 21f2a3ae946c..546040e1b760 100644 --- a/static/app/components/events/autofix/v3/artifactLoadingDetails.tsx +++ b/static/app/components/events/autofix/v3/artifactLoadingDetails.tsx @@ -8,6 +8,7 @@ import { ResponseGroup, } from 'sentry/views/seerExplorer/components/chat/responseGroup'; import {findLatestTodos} from 'sentry/views/seerExplorer/components/chat/toolUse'; +import {isSeerErrorBlock} from 'sentry/views/seerExplorer/types'; import {ArtifactDetails} from './artifactDetails'; @@ -21,28 +22,12 @@ export function ArtifactLoadingDetails({ blocks, }: ArtifactLoadingDetailsProps) { const [appearedAt] = useState(() => new Date()); - const responses = useMemo( - () => - groupTranscript(blocks).flatMap(segment => - segment.kind === 'response' ? [segment] : [] - ), - [blocks] - ); - const latestTodos = useMemo(() => findLatestTodos(blocks), [blocks]); + const transcript = useStepTranscript(blocks); return ( - {responses.length ? ( - responses.map(segment => ( - - )) + {transcript.responses.length ? ( + ) : ( @@ -51,3 +36,54 @@ export function ArtifactLoadingDetails({ ); } + +/** + * What a step wrote before it stopped, drawn as it was while the step ran. + * Seer's own error block is left out, since the caller explains why the step + * stopped. + */ +export function ArtifactPartialDetails({blocks}: {blocks: AutofixSection['blocks']}) { + const partialBlocks = useMemo( + () => blocks.filter(block => !isSeerErrorBlock(block)), + [blocks] + ); + const transcript = useStepTranscript(partialBlocks); + + if (!transcript.responses.length) { + return null; + } + + return ( + + + + ); +} + +function useStepTranscript(blocks: AutofixSection['blocks']) { + const responses = useMemo( + () => + groupTranscript(blocks).flatMap(segment => + segment.kind === 'response' ? [segment] : [] + ), + [blocks] + ); + const latestTodos = useMemo(() => findLatestTodos(blocks), [blocks]); + return {responses, latestTodos}; +} + +/** + * A step's responses, the way the Seer Agent chat draws them. + */ +function StepResponses({responses, latestTodos}: ReturnType) { + return responses.map(segment => ( + + )); +} diff --git a/static/app/components/events/autofix/v3/autofixCards.spec.tsx b/static/app/components/events/autofix/v3/autofixCards.spec.tsx index e96e8d078dad..91e70b70542d 100644 --- a/static/app/components/events/autofix/v3/autofixCards.spec.tsx +++ b/static/app/components/events/autofix/v3/autofixCards.spec.tsx @@ -12,6 +12,8 @@ import type { SolutionArtifact, useExplorerAutofix, } from 'sentry/components/events/autofix/useExplorerAutofix'; +import type {SeerOutOfCreditsCtaProps} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; +import {registerOverride} from 'sentry/overrideRegistry'; import {seerCreditCheckApiOptions} from 'sentry/utils/seer/useSeerCreditCheck'; import type { ExplorerCodingAgentState, @@ -2702,3 +2704,192 @@ describe('ArtifactCard', () => { }); }); }); + +describe('out of credits', () => { + // getsentry fills in the CTA; this stand-in shows the headline it was given. + function StubOutOfCreditsCta({message}: SeerOutOfCreditsCtaProps) { + return
{message}
; + } + + beforeEach(() => { + registerOverride('component:seer-out-of-credits-cta', () => StubOutOfCreditsCta); + }); + + function makeOutOfCreditsSection( + step: string, + blocks: AutofixSection['blocks'], + artifacts: AutofixArtifact[] = [] + ): AutofixSection { + return { + ...makeSection(step, 'error', artifacts, blocks), + failureReason: 'out_of_credits', + }; + } + + function makeBlock( + id: string, + content: string, + metadata?: Record + ): AutofixSection['blocks'][number] { + return { + id, + timestamp: '2026-01-01T00:00:00Z', + message: {role: 'assistant', content, metadata}, + }; + } + + // Seer appends this when it stops a run for credits. + const seerErrorBlock = makeBlock( + 'error-1', + "Your organization doesn't have enough Seer credits for this, so I stopped here." + ); + + const autofixWithStoppedRun: ReturnType = { + ...mockAutofix, + runState: { + run_id: 123, + blocks: [], + status: 'error', + failure_reason: 'out_of_credits', + updated_at: '2026-01-01T00:00:00Z', + }, + }; + + it('keeps what the root cause step wrote and offers the CTA instead of a re-run', () => { + render( + + ); + + expect(screen.getByText('The cache key omits the org slug.')).toBeInTheDocument(); + expect(screen.getByTestId('seer-out-of-credits')).toHaveTextContent( + 'Seer stopped before finishing the root cause because your organization is out of credits.' + ); + expect(screen.queryByText(/This one is on us/)).not.toBeInTheDocument(); + expect(screen.queryByText(/so I stopped here/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Re-run'})).not.toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Re-run step'})).toBeDisabled(); + }); + + it('keeps what the plan step wrote and offers the CTA instead of a re-run', () => { + render( + + ); + + expect(screen.getByText('Seed the reduction with zero.')).toBeInTheDocument(); + expect(screen.getByTestId('seer-out-of-credits')).toHaveTextContent( + 'Seer stopped before finishing the plan because your organization is out of credits.' + ); + expect(screen.queryByRole('button', {name: 'Re-run'})).not.toBeInTheDocument(); + }); + + it('keeps the partial diff of code changes that stopped for credits', () => { + render( + + ); + + expect(screen.getByText('Editing the cart service.')).toBeInTheDocument(); + expect(screen.getByTestId('file-diff-viewer')).toBeInTheDocument(); + expect(screen.getByTestId('seer-out-of-credits')).toHaveTextContent( + 'Seer stopped before finishing the code changes because your organization is out of credits.' + ); + expect( + screen.queryByText("Seer proposed a fix but couldn't apply it automatically") + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', {name: 'Add context & retry'}) + ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Re-run'})).not.toBeInTheDocument(); + // Partial changes are not the finished artifact. + expect(screen.getByRole('button', {name: 'Copy as Markdown'})).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + it('says a PR iteration stopped for credits', () => { + render( + , + {organization: manualPrIterationOrganization} + ); + + expect(screen.getByTestId('seer-out-of-credits')).toHaveTextContent( + 'Seer stopped before updating the pull request because your organization is out of credits.' + ); + // Only the iteration that stopped is replayed, not the original step. + expect(screen.queryByText('Applied the fix.')).not.toBeInTheDocument(); + }); + + it('offers the CTA instead of retrying a PR that failed for credits', () => { + render( + + ); + + expect(screen.getByTestId('seer-out-of-credits')).toHaveTextContent( + "Seer couldn't open a pull request in org/api because your organization is out of credits." + ); + expect( + screen.queryByRole('button', {name: 'Retry PR in org/api'}) + ).not.toBeInTheDocument(); + // Other failures stay retryable. + expect(screen.getByRole('button', {name: 'Retry PR in org/web'})).toBeEnabled(); + }); +}); diff --git a/static/app/components/events/autofix/v3/autofixPreviews.spec.tsx b/static/app/components/events/autofix/v3/autofixPreviews.spec.tsx index 1555f379c7ca..02b87dea1bd6 100644 --- a/static/app/components/events/autofix/v3/autofixPreviews.spec.tsx +++ b/static/app/components/events/autofix/v3/autofixPreviews.spec.tsx @@ -213,6 +213,33 @@ describe('CodeChangesPreview', () => { }); }); +describe('a step that stopped for credits', () => { + function makeStoppedSection(step: string): AutofixSection { + return { + step, + artifacts: [], + blocks: [], + status: 'error', + failureReason: 'out_of_credits', + }; + } + + it.each([ + [RootCausePreview, 'root_cause', 'Seer stopped before finishing the root cause'], + [SolutionPreview, 'solution', 'Seer stopped before finishing the plan'], + [ + CodeChangesPreview, + 'code_changes', + 'Seer stopped before finishing the code changes', + ], + ])('says so instead of blaming Seer (%#)', (Preview, step, message) => { + render(); + + expect(screen.getByText(new RegExp(message))).toBeInTheDocument(); + expect(screen.queryByText(/This one is on us/)).not.toBeInTheDocument(); + }); +}); + describe('PullRequestsPreview', () => { function makePR(overrides: Partial = {}): RepoPRState { return { diff --git a/static/app/components/events/autofix/v3/autofixPreviews.tsx b/static/app/components/events/autofix/v3/autofixPreviews.tsx index 4f9e019f4db8..72e198b1ead7 100644 --- a/static/app/components/events/autofix/v3/autofixPreviews.tsx +++ b/static/app/components/events/autofix/v3/autofixPreviews.tsx @@ -24,6 +24,7 @@ import { getSingleRunStop, isCodeChangesArtifact, isCodingAgentsArtifact, + isOutOfCreditsSection, isPullRequestsArtifact, isRootCauseArtifact, isSolutionArtifact, @@ -32,6 +33,7 @@ import { import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Placeholder} from 'sentry/components/placeholder'; import {SeerMarkdown} from 'sentry/components/seer/markdown'; +import {OUT_OF_CREDITS_STEP_MESSAGE} from 'sentry/components/seer/outOfCredits/messages'; import {t, tn} from 'sentry/locale'; interface ArtifactPreviewProps { @@ -56,6 +58,8 @@ export function RootCausePreview({section}: ArtifactPreviewProps) { ) : stop ? ( + ) : isOutOfCreditsSection(section) ? ( + {OUT_OF_CREDITS_STEP_MESSAGE.root_cause} ) : ( {t( @@ -82,6 +86,8 @@ export function SolutionPreview({section}: ArtifactPreviewProps) { ) : artifact?.data ? ( + ) : isOutOfCreditsSection(section) ? ( + {OUT_OF_CREDITS_STEP_MESSAGE.solution} ) : ( {t('Seer failed to generate a plan. This one is on us. Try running it again.')} @@ -110,6 +116,10 @@ export function CodeChangesPreview({section}: ArtifactPreviewProps) { } } + if (isOutOfCreditsSection(section)) { + return {OUT_OF_CREDITS_STEP_MESSAGE.code_changes}; + } + if (reposChanged === 0) { return ( @@ -135,7 +145,7 @@ export function CodeChangesPreview({section}: ArtifactPreviewProps) { return ( {t('%s files changed in %s repos', filesChanged.size, reposChanged)} ); - }, [patchesByRepo]); + }, [patchesByRepo, section]); return ( } title={t('Code Changes')}> diff --git a/static/app/components/events/autofix/v3/codeChangesCard.stories.tsx b/static/app/components/events/autofix/v3/codeChangesCard.stories.tsx index 44265a57c1c7..989f65b624ad 100644 --- a/static/app/components/events/autofix/v3/codeChangesCard.stories.tsx +++ b/static/app/components/events/autofix/v3/codeChangesCard.stories.tsx @@ -110,6 +110,29 @@ function makeAssistantBlock( }; } +function makeStepBlock( + step: string | undefined, + content: string +): AutofixSection['blocks'][number] { + return { + id: `block-${step ?? 'continued'}`, + timestamp: '2026-07-20T00:00:00Z', + message: {role: 'assistant', content, metadata: step ? {step} : undefined}, + }; +} + +// The block Seer appends when a run stops for credits. The card leaves it out +// in favor of the CTA. +const SEER_OUT_OF_CREDITS_BLOCK: AutofixSection['blocks'][number] = { + id: 'error-1', + timestamp: '2026-07-20T00:00:00Z', + message: { + role: 'assistant', + content: + "Your organization doesn't have enough Seer credits for this, so I stopped here. Add credits to continue.", + }, +}; + function makeSection( status: AutofixSection['status'], blocks: AutofixSection['blocks'], @@ -436,6 +459,77 @@ export default Storybook.story('CodeChangesCard Feedback', story => { ); }); + story('Stopped for credits', () => ( + + + The organization ran out of Seer credits while Seer was writing the code changes. + The run ends with failure_reason: out_of_credits. The card keeps what + Seer wrote and the partial diff, and the out-of-credits CTA takes the place of the + retry buttons. Copy as Markdown and Re-run step are disabled, and the drawer + offers no “Yes, draft a PR” for a run that stopped, so Create PR is not available. + + + + )); + + story('PR iteration stopped for credits', () => { + const user = useUser(); + return ( + + + The organization ran out of credits while Seer was working on feedback for an + open PR. The feedback item is marked failed (hover the red icon), the code + already pushed stays on screen, and the CTA says the PR update stopped. + + + + ); + }); + story('PR reviews', () => { return ( diff --git a/static/app/components/events/autofix/v3/codeChangesCard.tsx b/static/app/components/events/autofix/v3/codeChangesCard.tsx index 63d521a07992..7a298d5d68f9 100644 --- a/static/app/components/events/autofix/v3/codeChangesCard.tsx +++ b/static/app/components/events/autofix/v3/codeChangesCard.tsx @@ -14,6 +14,7 @@ import { collectPatches, getAutofixArtifactFromSection, isCodeChangesArtifact, + isOutOfCreditsSection, isPrIterationBlock, isPrIterationPaused, isSingleRunAutofix, @@ -21,6 +22,8 @@ import { type useExplorerAutofix, } from 'sentry/components/events/autofix/useExplorerAutofix'; import {SeerMarkdown} from 'sentry/components/seer/markdown'; +import {OUT_OF_CREDITS_STEP_MESSAGE} from 'sentry/components/seer/outOfCredits/messages'; +import {SeerOutOfCreditsCta} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; import {t, tn} from 'sentry/locale'; import {defined} from 'sentry/utils/defined'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; @@ -30,7 +33,7 @@ import {FileDiffViewer} from 'sentry/views/seerExplorer/components/fileDiffViewe import {ArtifactCard} from './artifactCard'; import {ArtifactDetails} from './artifactDetails'; -import {ArtifactLoadingDetails} from './artifactLoadingDetails'; +import {ArtifactLoadingDetails, ArtifactPartialDetails} from './artifactLoadingDetails'; import {AutofixResetPrompt} from './autofixResetPrompt'; import {FeedbackList, usePrIterationFeedback} from './feedbackList'; import { @@ -85,21 +88,32 @@ export function CodeChangesCard({autofix, groupId, section}: CodeChangesCardProp const {feedback, latestIterationIndex} = usePrIterationFeedback(section, autofix); - const loadingBlocks = useMemo(() => { - if (section.status !== 'processing') { + const isOutOfCredits = isOutOfCreditsSection(section); + + // The blocks of the step that is running, or that stopped for credits. + const currentStepBlocks = useMemo(() => { + if (section.status !== 'processing' && !isOutOfCredits) { return []; } if (currentStepStart === -1) { return section.blocks; } return section.blocks.slice(currentStepStart); - }, [section.status, section.blocks, currentStepStart]); + }, [section.status, section.blocks, currentStepStart, isOutOfCredits]); const artifact = useMemo(() => { const sectionArtifact = getAutofixArtifactFromSection(section); return isCodeChangesArtifact(sectionArtifact) ? sectionArtifact : null; }, [section]); + // A step that stopped for credits keeps the diff it got to, which is not + // the finished artifact, so it can't be copied or turned into a PR. + const partialArtifact = useMemo( + () => + isOutOfCredits ? (section.artifacts.findLast(isCodeChangesArtifact) ?? null) : null, + [isOutOfCredits, section.artifacts] + ); + const {copy} = useCopyToClipboard(); const isSingleRun = isSingleRunAutofix(autofix.runState); const singleRunRerun = useRerunSingleRun(autofix, { @@ -123,6 +137,8 @@ export function CodeChangesCard({autofix, groupId, section}: CodeChangesCardProp const isResetEligible = !isPaused && !hasFailedOnlyPRs && + // A re-run fails the same way until someone adds credits. + !isOutOfCredits && noCodingAgents && (hasPRs || autofix.runState?.status !== 'processing'); @@ -149,7 +165,10 @@ export function CodeChangesCard({autofix, groupId, section}: CodeChangesCardProp ); }, [location, navigate, shouldShowReset]); - const patchesByRepo = useMemo(() => collectPatches(artifact ?? []), [artifact]); + const patchesByRepo = useMemo( + () => collectPatches(artifact ?? partialArtifact ?? []), + [artifact, partialArtifact] + ); const shouldExpandDiffs = useMemo(() => { let lineCount = 0; @@ -244,6 +263,32 @@ export function CodeChangesCard({autofix, groupId, section}: CodeChangesCardProp ); } + const diffDetails = ( + + + {summary} + + {Array.from(patchesByRepo.entries(), ([repo, patches]) => ( + + + {t('Repository:')} + {repo} + + {patches.map((patch, index) => ( + + ))} + + ))} + + ); + let content: React.ReactNode; if (section.status === 'processing') { content = ( @@ -252,7 +297,7 @@ export function CodeChangesCard({autofix, groupId, section}: CodeChangesCardProp the form available even mid-run. */} {shouldShowReset && showPrIterationForm && prIterationForm} - {summary} - - {Array.from(patchesByRepo.entries(), ([repo, patches]) => ( - - - {t('Repository:')} - {repo} - - {patches.map((patch, index) => ( - - ))} - - ))} + {diffDetails} + + ); + } else if (isOutOfCredits) { + content = ( + + + {patchesByRepo.size > 0 && diffDetails} + ); } else if (explanation && isLatestStepPrIteration) { diff --git a/static/app/components/events/autofix/v3/feedbackList.spec.tsx b/static/app/components/events/autofix/v3/feedbackList.spec.tsx index 7c043f93dd4b..1786d0c889ec 100644 --- a/static/app/components/events/autofix/v3/feedbackList.spec.tsx +++ b/static/app/components/events/autofix/v3/feedbackList.spec.tsx @@ -138,4 +138,82 @@ describe('FeedbackList', () => { expect(screen.getByText('rename it')).toBeInTheDocument(); expect(screen.getByTestId('feedback-processed')).toBeInTheDocument(); }); + + function iterationBlocks() { + return [ + ExplorerAutofixBlockFixture({ + id: 'iteration-0', + artifacts: [], + message: { + role: 'user', + content: 'Please add a null check', + metadata: { + step: 'pr_iteration', + iteration_index: '0', + feedback: JSON.stringify({ + text: 'Please add a null check', + source: {type: 'user-ui'}, + }), + }, + }, + }), + ]; + } + + it('says the iteration stopped for credits', async () => { + const state = ExplorerAutofixStateFixture({ + status: 'error', + failure_reason: 'out_of_credits', + blocks: [ + ...iterationBlocks(), + ExplorerAutofixBlockFixture({ + id: 'error-1', + artifacts: [], + message: { + role: 'assistant', + content: "Your organization doesn't have enough Seer credits for this.", + metadata: null, + }, + }), + ], + }); + + render(); + + await userEvent.hover(screen.getByRole('img', {name: 'Feedback failed'})); + expect( + await screen.findByText( + "Seer couldn't finish this feedback because your organization is out of credits." + ) + ).toBeInTheDocument(); + }); + + it('says the iteration push failed for credits', async () => { + const state = ExplorerAutofixStateFixture({ + status: 'completed', + blocks: [ + ...iterationBlocks(), + ExplorerAutofixBlockFixture({ + id: 'iteration-completed', + artifacts: [], + message: {role: 'assistant', content: 'Done', metadata: null}, + }), + ], + repo_pr_states: { + 'org/repo': AutofixRepoPRStateFixture({ + pr_creation_status: 'error', + pr_creation_error_code: 'out_of_credits', + }), + }, + }); + + render(); + + await userEvent.hover(screen.getByRole('img', {name: 'Feedback failed'})); + expect( + await screen.findByText( + "Seer couldn't finish this feedback because your organization is out of credits." + ) + ).toBeInTheDocument(); + }); }); diff --git a/static/app/components/events/autofix/v3/feedbackList.tsx b/static/app/components/events/autofix/v3/feedbackList.tsx index a45ef1050bb3..6e825225e390 100644 --- a/static/app/components/events/autofix/v3/feedbackList.tsx +++ b/static/app/components/events/autofix/v3/feedbackList.tsx @@ -18,6 +18,7 @@ import {Tooltip} from '@sentry/scraps/tooltip'; import {CollapsibleContent} from 'sentry/components/ai/chat/collapsibleContent'; import { + isOutOfCreditsSection, isPrIterationBlock, type AutofixSection, type RawFeedback, @@ -29,6 +30,7 @@ import {t} from 'sentry/locale'; import type {AvatarUser, User} from 'sentry/types/user'; import {defined} from 'sentry/utils/defined'; import {userDisplayName} from 'sentry/utils/formatters'; +import {isOutOfCreditsPRState} from 'sentry/views/seerExplorer/types'; import {ArtifactDetails} from './artifactDetails'; @@ -198,9 +200,12 @@ export function usePrIterationFeedback( const blockFeedback = useMemo(() => { const repoStates = Object.values(autofix.runState?.repo_pr_states ?? {}); const pushErrors = repoStates.filter(repo => repo.pr_creation_status === 'error'); - const errorCode = pushErrors.find( - repo => repo.pr_creation_error_code === 'branch_moved' - )?.pr_creation_error_code; + // Running out of credits stops either the iteration itself or its push. + const errorCode = + isOutOfCreditsSection(section) || pushErrors.some(isOutOfCreditsPRState) + ? 'out_of_credits' + : pushErrors.find(repo => repo.pr_creation_error_code === 'branch_moved') + ?.pr_creation_error_code; const inProgress = section.status === 'processing' || autofix.runState?.status === 'processing' || @@ -251,7 +256,7 @@ export function usePrIterationFeedback( errorCode: status === 'error' ? errorCode : undefined, })); }); - }, [section.blocks, section.status, autofix.runState, currentStepStart]); + }, [section, autofix.runState, currentStepStart]); const latestIterationIndex = useMemo( () => @@ -797,6 +802,21 @@ function PrimaryIconAvatar({Icon, tooltip}: {Icon: typeof IconSeer; tooltip: str ); } +function getFeedbackErrorMessage(errorCode: string | null | undefined): string { + switch (errorCode) { + case 'branch_moved': + return t( + 'Seer did not push these changes because it would have had to force-push over newer commits on the PR branch.' + ); + case 'out_of_credits': + return t( + "Seer couldn't finish this feedback because your organization is out of credits." + ); + default: + return t('Changes from this feedback could not be pushed'); + } +} + // Source-agnostic: the same status glyphs regardless of where the iteration came from. function FeedbackStatusIcon({ status, @@ -808,16 +828,7 @@ function FeedbackStatusIcon({ switch (status) { case 'error': return ( - + { ); }); + story('Failed for credits', () => { + return ( + + + Creating the PR failed because the organization ran out of Seer credits ( + pr_creation_error_code: out_of_credits). A retry would fail the + same way, so the out-of-credits CTA replaces the “Retry PR” button. + + + + + + + + + + + ); + }); + story('Every state at once', () => { return ( diff --git a/static/app/components/events/autofix/v3/pullRequestsCard.tsx b/static/app/components/events/autofix/v3/pullRequestsCard.tsx index 0bcac8621499..86f4aa5286fe 100644 --- a/static/app/components/events/autofix/v3/pullRequestsCard.tsx +++ b/static/app/components/events/autofix/v3/pullRequestsCard.tsx @@ -15,9 +15,12 @@ import { type AutofixSection, type useExplorerAutofix, } from 'sentry/components/events/autofix/useExplorerAutofix'; +import {getOutOfCreditsPRsMessage} from 'sentry/components/seer/outOfCredits/messages'; +import {SeerOutOfCreditsCta} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; import {t} from 'sentry/locale'; import {defined} from 'sentry/utils/defined'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; +import {isOutOfCreditsPRState} from 'sentry/views/seerExplorer/types'; import {ArtifactCard} from './artifactCard'; import {artifactToMarkdown} from './utils'; @@ -44,6 +47,8 @@ export function PullRequestsCard({autofix, section}: PullRequestsCardProps) { return null; } + const outOfCreditsPullRequests = artifact?.filter(isOutOfCreditsPRState) ?? []; + return ( } @@ -55,6 +60,12 @@ export function PullRequestsCard({autofix, section}: PullRequestsCardProps) { } > {artifact?.map(pullRequest => { + // Retrying fails the same way until someone adds credits, so the CTA + // below stands in for the retry. + if (isOutOfCreditsPRState(pullRequest)) { + return null; + } + if (pullRequest.pr_creation_status === 'creating') { const isUpdating = defined(pullRequest.pr_number); return ( @@ -96,6 +107,11 @@ export function PullRequestsCard({autofix, section}: PullRequestsCardProps) { ); })} + {outOfCreditsPullRequests.length > 0 && ( + + )} ); } diff --git a/static/app/components/events/autofix/v3/rootCauseCard.tsx b/static/app/components/events/autofix/v3/rootCauseCard.tsx index ae70a19de78b..e75b1614732d 100644 --- a/static/app/components/events/autofix/v3/rootCauseCard.tsx +++ b/static/app/components/events/autofix/v3/rootCauseCard.tsx @@ -9,19 +9,22 @@ import {Text} from '@sentry/scraps/text'; import { getAutofixArtifactFromSection, getSingleRunStop, + isOutOfCreditsSection, isRootCauseArtifact, isSingleRunAutofix, type AutofixSection, type useExplorerAutofix, } from 'sentry/components/events/autofix/useExplorerAutofix'; import {SeerMarkdown} from 'sentry/components/seer/markdown'; +import {OUT_OF_CREDITS_STEP_MESSAGE} from 'sentry/components/seer/outOfCredits/messages'; +import {SeerOutOfCreditsCta} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; import {t} from 'sentry/locale'; import {defined} from 'sentry/utils/defined'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; import {ArtifactCard} from './artifactCard'; import {ArtifactDetails} from './artifactDetails'; -import {ArtifactLoadingDetails} from './artifactLoadingDetails'; +import {ArtifactLoadingDetails, ArtifactPartialDetails} from './artifactLoadingDetails'; import {AutofixEvidence} from './autofixEvidence'; import {AutofixResetPrompt} from './autofixResetPrompt'; import {useAutofixSectionEvidence} from './useAutofixSectionEvidence'; @@ -61,9 +64,13 @@ export function RootCauseCard({autofix, groupId, section}: RootCauseCardProps) { [artifact] ); + const isOutOfCredits = isOutOfCreditsSection(section); + const {canReset, shouldShowReset, setShouldShowReset, handleReset} = useResetAutofixStep({ autofix, + // A re-run fails the same way until someone adds credits. + canReset: isOutOfCredits ? false : undefined, section, step: 'root_cause', }); @@ -75,6 +82,22 @@ export function RootCauseCard({autofix, groupId, section}: RootCauseCardProps) { }); const evidence = useAutofixSectionEvidence({section}); + const evidenceDetails = + evidence.length > 0 ? ( + + {t('Evidence')} + + {evidence.map(e => ( + + ))} + + + ) : null; return ( ) : null} - {evidence.length > 0 && ( - - {t('Evidence')} - - {evidence.map(e => ( - - ))} - - - )} + {evidenceDetails} {stopDetails} + ) : isOutOfCredits ? ( + + + {evidenceDetails} + + ) : stopDetails ? ( stopDetails ) : ( diff --git a/static/app/components/events/autofix/v3/solutionCard.tsx b/static/app/components/events/autofix/v3/solutionCard.tsx index 1a74fce7ce32..1139a93495ca 100644 --- a/static/app/components/events/autofix/v3/solutionCard.tsx +++ b/static/app/components/events/autofix/v3/solutionCard.tsx @@ -8,18 +8,21 @@ import {Text} from '@sentry/scraps/text'; import { getAutofixArtifactFromSection, + isOutOfCreditsSection, isSolutionArtifact, type AutofixSection, type useExplorerAutofix, } from 'sentry/components/events/autofix/useExplorerAutofix'; import {SeerMarkdown} from 'sentry/components/seer/markdown'; +import {OUT_OF_CREDITS_STEP_MESSAGE} from 'sentry/components/seer/outOfCredits/messages'; +import {SeerOutOfCreditsCta} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; import {t} from 'sentry/locale'; import {defined} from 'sentry/utils/defined'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; import {ArtifactCard} from './artifactCard'; import {ArtifactDetails} from './artifactDetails'; -import {ArtifactLoadingDetails} from './artifactLoadingDetails'; +import {ArtifactLoadingDetails, ArtifactPartialDetails} from './artifactLoadingDetails'; import {AutofixResetPrompt} from './autofixResetPrompt'; import {useResetAutofixStep} from './useResetAutofixStep'; import {useRethinkInChat} from './useRethinkInChat'; @@ -42,9 +45,13 @@ export function SolutionCard({autofix, section}: SolutionCardProps) { [artifact] ); + const isOutOfCredits = isOutOfCreditsSection(section); + const {canReset, shouldShowReset, setShouldShowReset, handleReset} = useResetAutofixStep({ autofix, + // A re-run fails the same way until someone adds credits. + canReset: isOutOfCredits ? false : undefined, section, step: 'solution', }); @@ -104,6 +111,11 @@ export function SolutionCard({autofix, section}: SolutionCardProps) { ) : null} + ) : isOutOfCredits ? ( + + + + ) : ( diff --git a/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx b/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx index 0094c22754ed..41249b5651b3 100644 --- a/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx @@ -493,6 +493,94 @@ describe('AutofixRef embed', () => { } ); + describe('out of credits', () => { + // getsentry fills in the CTA; this stand-in shows the headline it was given. + function StubOutOfCreditsCta({message}: SeerOutOfCreditsCtaProps) { + return
{message}
; + } + + beforeEach(() => { + registerOverride('component:seer-out-of-credits-cta', () => StubOutOfCreditsCta); + }); + + function makeStoppedRun(step: AutofixExplorerStep): ExplorerAutofixState { + const run = makeRunThrough(step, {status: 'error'}); + return { + ...run, + failure_reason: 'out_of_credits', + blocks: [ + ...run.blocks, + { + id: 'error-1', + timestamp: '2026-01-01T00:00:00Z', + message: { + role: 'assistant', + content: "Your organization doesn't have enough Seer credits for this.", + }, + }, + ], + }; + } + + function withOutOfCreditsPR(run: ExplorerAutofixState): ExplorerAutofixState { + const prState = run.repo_pr_states!['getsentry/sentry']!; + return { + ...run, + repo_pr_states: { + 'getsentry/sentry': {...prState, pr_creation_error_code: 'out_of_credits'}, + }, + }; + } + + it.each([ + ['root_cause', 'Seer stopped before finishing the root cause'], + ['solution', 'Seer stopped before finishing the plan'], + ['code_changes', 'Seer stopped before finishing the code changes'], + ] as const)( + 'keeps what the %s step wrote and offers the CTA instead of a retry', + async (step, message) => { + mockRun(makeStoppedRun(step)); + render( + + + , + {organization} + ); + await expandAll(1); + + expect(screen.getByText(`Finished ${step}`)).toBeInTheDocument(); + expect(screen.getByTestId('seer-out-of-credits')).toHaveTextContent(message); + expect(screen.queryByText(/This one is on us/)).not.toBeInTheDocument(); + expect(getButtons('Try again')).toHaveLength(0); + expect(getButtons('Draft a pull request')).toHaveLength(0); + } + ); + + it('does not offer the pull request again after a create failed for credits', async () => { + mockRun(withOutOfCreditsPR(makeRunThrough('code_changes', {pullRequest: 'error'}))); + render( + + + , + {organization} + ); + await expandAll(1); + + expect(getButtons('Draft a pull request')).toHaveLength(0); + }); + + it('offers the CTA on the pull request step when the create failed for credits', async () => { + mockRun(withOutOfCreditsPR(makeRunThrough('code_changes', {pullRequest: 'error'}))); + render(, {organization}); + await expandAll(1); + + expect(screen.getByTestId('seer-out-of-credits')).toHaveTextContent( + "Seer couldn't open a pull request in getsentry/sentry because your organization is out of credits." + ); + expect(screen.queryByText(/This one is on us/)).not.toBeInTheDocument(); + }); + }); + // One embed per step, as a conversation that walked the whole run holds. // Only the action the run has not taken yet is on offer, from one place. it('offers only the untaken action across a conversation spanning every step', async () => { diff --git a/static/app/components/seer/markdown/embeds/components/autofix.stories.tsx b/static/app/components/seer/markdown/embeds/components/autofix.stories.tsx index 60acfdca6c11..6de0a43df37b 100644 --- a/static/app/components/seer/markdown/embeds/components/autofix.stories.tsx +++ b/static/app/components/seer/markdown/embeds/components/autofix.stories.tsx @@ -1,4 +1,4 @@ -import {useMemo, useState, type ReactNode} from 'react'; +import {Fragment, useEffect, useMemo, useRef, useState, type ReactNode} from 'react'; import {IconArrow} from '@sentry/icons/arrow'; import { QueryClient, @@ -512,6 +512,58 @@ function creditsRun(blocks: Block[]): ExplorerAutofixState { }; } +// The block Seer appends when a run stops for credits. +const SEER_OUT_OF_CREDITS_BLOCK: Block = { + id: 'error-1', + timestamp: '2026-10-07T12:00:00Z', + loading: false, + message: { + role: 'assistant', + content: + "Your organization doesn't have enough Seer credits for this, so I stopped here. Add credits to continue.", + }, +}; + +// Root cause and plan finished; code changes stopped part way, one file in. +const CODE_CHANGES_STOPPED_RUN: ExplorerAutofixState = { + run_id: CREDITS_RUN_ID, + status: 'error', + failure_reason: 'out_of_credits', + updated_at: '2026-10-07T12:00:00Z', + blocks: [ + ROOT_CAUSE_BLOCK, + SOLUTION_BLOCK, + { + ...CODE_CHANGES_BLOCK, + message: { + ...CODE_CHANGES_BLOCK.message, + content: + 'Passed `0` as the initial accumulator in `cartService.ts`. Next up was the empty-cart test in `cartService.test.ts`.', + }, + }, + SEER_OUT_OF_CREDITS_BLOCK, + ], +}; + +// Code changes finished, then opening the PR failed for credits. +const PR_STOPPED_RUN: ExplorerAutofixState = { + ...creditsRun([ROOT_CAUSE_BLOCK, SOLUTION_BLOCK, CODE_CHANGES_BLOCK]), + repo_pr_states: { + 'acme/checkout': { + repo_name: 'acme/checkout', + branch_name: null, + commit_sha: null, + pr_creation_status: 'error', + pr_creation_error: "Your organization doesn't have enough Seer credits for this.", + pr_creation_error_code: 'out_of_credits', + pr_id: null, + pr_number: null, + pr_url: null, + title: null, + }, + }, +}; + /** * Answers the embeds' run request with the given run, the credit check with the * given balance, and every other request with an empty list. @@ -545,6 +597,25 @@ class CreditsQueryClient extends QueryClient { }; } +/** + * Autofix embeds ship collapsed. Open them so the state is visible without a + * click. + */ +function ExpandedEmbeds({children}: {children: ReactNode}) { + const ref = useRef(null); + const opened = useRef(false); + useEffect(() => { + if (opened.current) { + return; + } + opened.current = true; + ref.current + ?.querySelectorAll('button[aria-expanded="false"]') + .forEach(button => button.click()); + }, []); + return {children}; +} + /** * A conversation in an org with credit checks on, out of credits unless * `hasEnoughCredits` is set, where the chat can still send, so each embed @@ -555,12 +626,17 @@ function CreditsChat({ children, description, run, + expanded = false, hasEnoughCredits = false, isBillingMember = false, }: { children: ReactNode; description: ReactNode; run: ExplorerAutofixState; + /** + * Open the embeds on load, so the story needs no click. + */ + expanded?: boolean; hasEnoughCredits?: boolean; /** * Billing members configure auto-reload; everyone else contacts their admins. @@ -571,6 +647,21 @@ function CreditsChat({ const [queryClient] = useState(() => new CreditsQueryClient(run, hasEnoughCredits)); const {isAutofixOutOfCredits, autofixDenialReason, reportOutOfCredits} = useAutofixOutOfCreditsEmbeds(); + const chat = ( + + ) : null + } + > + {children} + + ); return ( @@ -595,19 +686,7 @@ function CreditsChat({ sendMessage={() => {}} reportOutOfCredits={reportOutOfCredits} > - - ) : null - } - > - {children} - + {expanded ? {chat} : chat} @@ -664,6 +743,48 @@ export default Storybook.story('Autofix', story => { )); + story('Stopped for credits', () => ( + + The organization ran out of Seer credits while Autofix was writing code changes, + so the run ended with failure_reason: out_of_credits. The card keeps + what Seer wrote and the partial diff, and the out-of-credits CTA replaces “Try + again”, which would only fail again. No “Draft a pull request” is offered for + the unfinished changes. + + } + run={CODE_CHANGES_STOPPED_RUN} + expanded + > + Fix {ISSUE.shortId} all the way. + + + )); + + story('Pull request failed for credits', () => ( + + The code changes finished, then opening the PR failed because the organization + ran out of credits (pr_creation_error_code: out_of_credits). The + Pull Request card shows the CTA in place of Seer's generic failure, and the Code + Changes card stops offering “Draft a pull request”, which would fail the same + way. + + } + run={PR_STOPPED_RUN} + expanded + > + Open a pull request for {ISSUE.shortId}. + + + )); + story('Live Autofix run (autofixRef)', () => ); story('Out of credits: after the root cause', () => ( diff --git a/static/app/components/seer/markdown/embeds/components/autofix.tsx b/static/app/components/seer/markdown/embeds/components/autofix.tsx index 8a0cfe226257..43b75c9c7d7f 100644 --- a/static/app/components/seer/markdown/embeds/components/autofix.tsx +++ b/static/app/components/seer/markdown/embeds/components/autofix.tsx @@ -23,6 +23,9 @@ import { getAutofixArtifactFromSection, getOrderedAutofixSections, isCodeChangesArtifact, + isLastStepPrIteration, + isOutOfCreditsSection, + isPrIterationBlock, isPullRequestsArtifact, isPullRequestsSection, isCodeChangesSection, @@ -36,17 +39,24 @@ import { } from 'sentry/components/events/autofix/useExplorerAutofix'; import {useRefreshAutofixProgressQueries} from 'sentry/components/events/autofix/useRefreshAutofixProgressQueries'; import {ArtifactDetails} from 'sentry/components/events/autofix/v3/artifactDetails'; +import {ArtifactPartialDetails} from 'sentry/components/events/autofix/v3/artifactLoadingDetails'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import { useAutofixChat, useReportAutofixOutOfCredits, } from 'sentry/components/seer/autofixChatContext'; import {defineSeerEmbed} from 'sentry/components/seer/markdown/embeds/utils'; +import { + getOutOfCreditsPRsMessage, + OUT_OF_CREDITS_STEP_MESSAGE, +} from 'sentry/components/seer/outOfCredits/messages'; import {SeerOutOfCreditsCta} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; import {t, tn} from 'sentry/locale'; import type {Group} from 'sentry/types/group'; +import {defined} from 'sentry/utils/defined'; import {useOrganization} from 'sentry/utils/useOrganization'; import {FileDiffViewer} from 'sentry/views/seerExplorer/components/fileDiffViewer'; +import {isOutOfCreditsPRState} from 'sentry/views/seerExplorer/types'; import {resourceLinkMarkdown} from './resourceLink'; import {SeerEmbedBlock} from './seerEmbedBlock'; @@ -293,9 +303,12 @@ function AutofixRefContent({id, shortId, step}: AutofixRefContentProps) { const nextStep = isSingleRunAutofix(runState ?? null) ? undefined : NEXT_STEP[step]; const pendingNextStep = nextStep && !findStepSection(sections, nextStep) ? nextStep : undefined; - // A failed create also lands in `repo_pr_states`; that one should stay retryable. + // A failed create also lands in `repo_pr_states`; that one should stay + // retryable, unless it failed for credits and would fail the same way again. const canCreatePR = - step === 'code_changes' && !hasCreatedPullRequests(runState?.repo_pr_states); + step === 'code_changes' && + !hasCreatedPullRequests(runState?.repo_pr_states) && + !Object.values(runState?.repo_pr_states ?? {}).some(isOutOfCreditsPRState); const showsNextAction = section?.status === 'completed' && (canCreatePR || !!pendingNextStep); @@ -323,11 +336,25 @@ function AutofixRefContent({id, shortId, step}: AutofixRefContentProps) { creditCheck.denialReason ); + // Once a PR exists this embed shows the PR section, so an iteration that ran + // out of credits is only visible on the code_changes section it folds into. + const codeChangesSection = sections.find(isCodeChangesSection); + const iterationOutOfCredits = + step === 'pr_iteration' && + !!codeChangesSection && + isOutOfCreditsSection(codeChangesSection) && + isLastStepPrIteration(runState); + return ( - - {section?.status === 'error' && ( + + {section?.status === 'error' && !isOutOfCreditsSection(section) && ( + + ); + } content = ( {patchesByRepo.size > 0 && diffDetails} - + + {retry} + ); } else if (explanation && isLatestStepPrIteration) { @@ -412,7 +442,7 @@ export function CodeChangesCard({autofix, groupId, section}: CodeChangesCardProp allowReset={!isSingleRun} onReset={canReset ? (resetInChat ?? (() => setShouldShowReset(true))) : undefined} resetInChat={defined(resetInChat)} - resetTooltip={isPaused ? PR_ITERATION_PAUSED_TOOLTIP : undefined} + resetTooltip={isPaused ? PR_ITERATION_PAUSED_TOOLTIP : outOfCreditsRetry.tooltip} > {content} diff --git a/static/app/components/events/autofix/v3/outOfCreditsStatus.tsx b/static/app/components/events/autofix/v3/outOfCreditsStatus.tsx new file mode 100644 index 000000000000..275831a00a1a --- /dev/null +++ b/static/app/components/events/autofix/v3/outOfCreditsStatus.tsx @@ -0,0 +1,27 @@ +import type {ReactNode} from 'react'; + +import {Text} from '@sentry/scraps/text'; + +import {ArtifactDetails} from './artifactDetails'; + +interface OutOfCreditsStatusProps { + message: string; + /** + * The step's retry, disabled while the organization can't afford it, or the + * re-run prompt it opened. + */ + children?: ReactNode; +} + +/** + * Says a step stopped because the organization ran out of Seer credits. The + * credit gates show the CTA to add credits, so this only says what happened. + */ +export function OutOfCreditsStatus({children, message}: OutOfCreditsStatusProps) { + return ( + + {message} + {children} + + ); +} diff --git a/static/app/components/events/autofix/v3/pullRequestsCard.stories.tsx b/static/app/components/events/autofix/v3/pullRequestsCard.stories.tsx index 3087cc9e1bce..de25fcc94397 100644 --- a/static/app/components/events/autofix/v3/pullRequestsCard.stories.tsx +++ b/static/app/components/events/autofix/v3/pullRequestsCard.stories.tsx @@ -8,6 +8,7 @@ import type { ExplorerAutofixState, useExplorerAutofix, } from 'sentry/components/events/autofix/useExplorerAutofix'; +import {SeerCreditCheckStub} from 'sentry/components/seer/outOfCredits/__stories__/seerCreditCheckStub'; import * as Storybook from 'sentry/stories'; import type {RepoPRState} from 'sentry/views/seerExplorer/types'; @@ -159,28 +160,35 @@ export default Storybook.story('PullRequestsCard', story => { Creating the PR failed because the organization ran out of Seer credits ( - pr_creation_error_code: out_of_credits). A retry would fail the - same way, so the out-of-credits CTA replaces the “Retry PR” button. + pr_creation_error_code: out_of_credits). A line under the PRs says + so, and the credit check for autofix_create_pr says the + organization still can't afford it, so its “Retry PR” is disabled (hover it for + why). The out-of-credits CTA is not here: the alert at the top of the Autofix + tab and drawer covers it. - - - - - - - + + + + + + + + + + + ); }); diff --git a/static/app/components/events/autofix/v3/pullRequestsCard.tsx b/static/app/components/events/autofix/v3/pullRequestsCard.tsx index 86f4aa5286fe..ea0963c80bf4 100644 --- a/static/app/components/events/autofix/v3/pullRequestsCard.tsx +++ b/static/app/components/events/autofix/v3/pullRequestsCard.tsx @@ -6,6 +6,7 @@ import {IconRefresh} from '@sentry/icons/refresh'; import {Button, LinkButton} from '@sentry/scraps/button'; import {Flex} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; import {getAutofixRunId} from 'sentry/components/events/autofix/autofixRunId'; import {getRepoPullRequestLink} from 'sentry/components/events/autofix/pullRequests'; @@ -15,8 +16,11 @@ import { type AutofixSection, type useExplorerAutofix, } from 'sentry/components/events/autofix/useExplorerAutofix'; -import {getOutOfCreditsPRsMessage} from 'sentry/components/seer/outOfCredits/messages'; -import {SeerOutOfCreditsCta} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; +import { + getOutOfCreditsPRMessage, + getOutOfCreditsPRsMessage, +} from 'sentry/components/seer/outOfCredits/messages'; +import {useOutOfCreditsRetry} from 'sentry/components/seer/outOfCredits/useOutOfCreditsRetry'; import {t} from 'sentry/locale'; import {defined} from 'sentry/utils/defined'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; @@ -43,12 +47,16 @@ export function PullRequestsCard({autofix, section}: PullRequestsCardProps) { [artifact] ); + const outOfCreditsPullRequests = artifact?.filter(isOutOfCreditsPRState) ?? []; + // Their "Retry PR" fails the same way until the organization can afford it. + const outOfCreditsRetry = useOutOfCreditsRetry('autofix_create_pr', { + enabled: defined(runId) && outOfCreditsPullRequests.length > 0, + }); + if (!runId) { return null; } - const outOfCreditsPullRequests = artifact?.filter(isOutOfCreditsPRState) ?? []; - return ( } @@ -60,12 +68,6 @@ export function PullRequestsCard({autofix, section}: PullRequestsCardProps) { } > {artifact?.map(pullRequest => { - // Retrying fails the same way until someone adds credits, so the CTA - // below stands in for the retry. - if (isOutOfCreditsPRState(pullRequest)) { - return null; - } - if (pullRequest.pr_creation_status === 'creating') { const isUpdating = defined(pullRequest.pr_number); return ( @@ -94,13 +96,19 @@ export function PullRequestsCard({autofix, section}: PullRequestsCardProps) { ); } + const isOutOfCredits = isOutOfCreditsPRState(pullRequest); return ( @@ -108,9 +116,7 @@ export function PullRequestsCard({autofix, section}: PullRequestsCardProps) { ); })} {outOfCreditsPullRequests.length > 0 && ( - + {getOutOfCreditsPRsMessage(outOfCreditsPullRequests)} )} ); diff --git a/static/app/components/events/autofix/v3/rootCauseCard.tsx b/static/app/components/events/autofix/v3/rootCauseCard.tsx index e75b1614732d..c3049a31e0e3 100644 --- a/static/app/components/events/autofix/v3/rootCauseCard.tsx +++ b/static/app/components/events/autofix/v3/rootCauseCard.tsx @@ -17,7 +17,7 @@ import { } from 'sentry/components/events/autofix/useExplorerAutofix'; import {SeerMarkdown} from 'sentry/components/seer/markdown'; import {OUT_OF_CREDITS_STEP_MESSAGE} from 'sentry/components/seer/outOfCredits/messages'; -import {SeerOutOfCreditsCta} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; +import {useOutOfCreditsRetry} from 'sentry/components/seer/outOfCredits/useOutOfCreditsRetry'; import {t} from 'sentry/locale'; import {defined} from 'sentry/utils/defined'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; @@ -27,6 +27,7 @@ import {ArtifactDetails} from './artifactDetails'; import {ArtifactLoadingDetails, ArtifactPartialDetails} from './artifactLoadingDetails'; import {AutofixEvidence} from './autofixEvidence'; import {AutofixResetPrompt} from './autofixResetPrompt'; +import {OutOfCreditsStatus} from './outOfCreditsStatus'; import {useAutofixSectionEvidence} from './useAutofixSectionEvidence'; import {useResetAutofixStep} from './useResetAutofixStep'; import {useRethinkInChat} from './useRethinkInChat'; @@ -65,17 +66,29 @@ export function RootCauseCard({autofix, groupId, section}: RootCauseCardProps) { ); const isOutOfCredits = isOutOfCreditsSection(section); + const outOfCreditsRetry = useOutOfCreditsRetry('autofix_root_cause', { + enabled: isOutOfCredits, + }); const {canReset, shouldShowReset, setShouldShowReset, handleReset} = useResetAutofixStep({ autofix, - // A re-run fails the same way until someone adds credits. - canReset: isOutOfCredits ? false : undefined, + // A step that stopped for credits fails the same way until the + // organization can afford it again. + canReset: outOfCreditsRetry.disabled ? false : undefined, section, step: 'root_cause', }); const rethinkPrompt = t('How can this root cause be improved?'); + const resetPrompt = shouldShowReset ? ( + setShouldShowReset(false)} + onReset={handleReset} + placeholder={t('Give seer additional context to improve this root cause.')} + prompt={rethinkPrompt} + /> + ) : null; const rethinkInChat = useRethinkInChat({ prompt: rethinkPrompt, step: 'root_cause', @@ -112,6 +125,7 @@ export function RootCauseCard({autofix, groupId, section}: RootCauseCardProps) { allowReset={!isSingleRunAutofix(autofix.runState)} onReset={canReset ? (rethinkInChat ?? (() => setShouldShowReset(true))) : undefined} resetInChat={defined(rethinkInChat)} + resetTooltip={outOfCreditsRetry.tooltip} > {section.status === 'processing' ? ( ) : artifact?.data ? ( - {shouldShowReset && ( - setShouldShowReset(false)} - onReset={handleReset} - placeholder={t('Give seer additional context to improve this root cause.')} - prompt={rethinkPrompt} - /> - )} + {resetPrompt} {artifact.data.cause ? : null} @@ -165,7 +172,21 @@ export function RootCauseCard({autofix, groupId, section}: RootCauseCardProps) { {evidenceDetails} - + + {resetPrompt ?? ( + + + + )} + ) : stopDetails ? ( stopDetails diff --git a/static/app/components/events/autofix/v3/solutionCard.tsx b/static/app/components/events/autofix/v3/solutionCard.tsx index 1139a93495ca..aa2da9168e61 100644 --- a/static/app/components/events/autofix/v3/solutionCard.tsx +++ b/static/app/components/events/autofix/v3/solutionCard.tsx @@ -3,7 +3,7 @@ import {IconList} from '@sentry/icons/list'; import {IconRefresh} from '@sentry/icons/refresh'; import {Button} from '@sentry/scraps/button'; -import {Container, Stack} from '@sentry/scraps/layout'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import { @@ -15,7 +15,7 @@ import { } from 'sentry/components/events/autofix/useExplorerAutofix'; import {SeerMarkdown} from 'sentry/components/seer/markdown'; import {OUT_OF_CREDITS_STEP_MESSAGE} from 'sentry/components/seer/outOfCredits/messages'; -import {SeerOutOfCreditsCta} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; +import {useOutOfCreditsRetry} from 'sentry/components/seer/outOfCredits/useOutOfCreditsRetry'; import {t} from 'sentry/locale'; import {defined} from 'sentry/utils/defined'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; @@ -24,6 +24,7 @@ import {ArtifactCard} from './artifactCard'; import {ArtifactDetails} from './artifactDetails'; import {ArtifactLoadingDetails, ArtifactPartialDetails} from './artifactLoadingDetails'; import {AutofixResetPrompt} from './autofixResetPrompt'; +import {OutOfCreditsStatus} from './outOfCreditsStatus'; import {useResetAutofixStep} from './useResetAutofixStep'; import {useRethinkInChat} from './useRethinkInChat'; import {artifactToMarkdown} from './utils'; @@ -46,17 +47,29 @@ export function SolutionCard({autofix, section}: SolutionCardProps) { ); const isOutOfCredits = isOutOfCreditsSection(section); + const outOfCreditsRetry = useOutOfCreditsRetry('autofix_solution', { + enabled: isOutOfCredits, + }); const {canReset, shouldShowReset, setShouldShowReset, handleReset} = useResetAutofixStep({ autofix, - // A re-run fails the same way until someone adds credits. - canReset: isOutOfCredits ? false : undefined, + // A step that stopped for credits fails the same way until the + // organization can afford it again. + canReset: outOfCreditsRetry.disabled ? false : undefined, section, step: 'solution', }); const rethinkPrompt = t('How can this plan be improved?'); + const resetPrompt = shouldShowReset ? ( + setShouldShowReset(false)} + onReset={handleReset} + placeholder={t('Give seer additional context to improve this plan.')} + prompt={rethinkPrompt} + /> + ) : null; const rethinkInChat = useRethinkInChat({ prompt: rethinkPrompt, step: 'solution', @@ -74,6 +87,7 @@ export function SolutionCard({autofix, section}: SolutionCardProps) { allowReset onReset={canReset ? (rethinkInChat ?? (() => setShouldShowReset(true))) : undefined} resetInChat={defined(rethinkInChat)} + resetTooltip={outOfCreditsRetry.tooltip} > {section.status === 'processing' ? ( ) : artifact?.data ? ( - {shouldShowReset && ( - setShouldShowReset(false)} - onReset={handleReset} - placeholder={t('Give seer additional context to improve this plan.')} - prompt={rethinkPrompt} - /> - )} + {resetPrompt} @@ -114,7 +121,21 @@ export function SolutionCard({autofix, section}: SolutionCardProps) { ) : isOutOfCredits ? ( - + + {resetPrompt ?? ( + + + + )} + ) : ( diff --git a/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx b/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx index 41249b5651b3..e1894124b193 100644 --- a/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx @@ -494,14 +494,29 @@ describe('AutofixRef embed', () => { ); describe('out of credits', () => { - // getsentry fills in the CTA; this stand-in shows the headline it was given. - function StubOutOfCreditsCta({message}: SeerOutOfCreditsCtaProps) { - return
{message}
; + const creditsOrganization = OrganizationFixture({ + slug: 'org-slug', + features: ['seer-credit-checks'], + }); + const retryTooltip = 'Your organization does not have enough Seer credits for this.'; + + function mockCreditCheck(operation: string, hasEnoughCredits: boolean) { + return MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/seer/credit-check/`, + match: [MockApiClient.matchQuery({operation})], + body: {hasEnoughCredits}, + }); } - beforeEach(() => { - registerOverride('component:seer-out-of-credits-cta', () => StubOutOfCreditsCta); - }); + // While the check is in flight the button is natively disabled. Once it + // settles it is only aria-disabled, so it stays focusable and its tooltip + // can say why. + async function expectDisabledForCredits(button: HTMLElement) { + await waitFor(() => expect(button).toBeEnabled()); + expect(button).toHaveAttribute('aria-disabled', 'true'); + await userEvent.hover(button); + expect(await screen.findByText(retryTooltip)).toBeInTheDocument(); + } function makeStoppedRun(step: AutofixExplorerStep): ExplorerAutofixState { const run = makeRunThrough(step, {status: 'error'}); @@ -533,50 +548,112 @@ describe('AutofixRef embed', () => { } it.each([ - ['root_cause', 'Seer stopped before finishing the root cause'], - ['solution', 'Seer stopped before finishing the plan'], - ['code_changes', 'Seer stopped before finishing the code changes'], + [ + 'root_cause', + 'autofix_root_cause', + 'Seer stopped before finishing the root cause', + ], + ['solution', 'autofix_solution', 'Seer stopped before finishing the plan'], + [ + 'code_changes', + 'autofix_code_changes', + 'Seer stopped before finishing the code changes', + ], ] as const)( - 'keeps what the %s step wrote and offers the CTA instead of a retry', - async (step, message) => { + 'keeps what the %s step wrote and disables Try again while out of credits', + async (step, operation, message) => { mockRun(makeStoppedRun(step)); + mockCreditCheck(operation, false); render( , - {organization} + {organization: creditsOrganization} ); await expandAll(1); expect(screen.getByText(`Finished ${step}`)).toBeInTheDocument(); - expect(screen.getByTestId('seer-out-of-credits')).toHaveTextContent(message); + expect(screen.getByText(new RegExp(message))).toBeInTheDocument(); expect(screen.queryByText(/This one is on us/)).not.toBeInTheDocument(); - expect(getButtons('Try again')).toHaveLength(0); + expect( + screen.queryByText(/doesn't have enough Seer credits/) + ).not.toBeInTheDocument(); expect(getButtons('Draft a pull request')).toHaveLength(0); + await expectDisabledForCredits(screen.getByRole('button', {name: 'Try again'})); } ); - it('does not offer the pull request again after a create failed for credits', async () => { + it('retries a step that stopped for credits once the organization can afford it', async () => { + mockRun(makeStoppedRun('code_changes')); + mockCreditCheck('autofix_code_changes', true); + const sendMessage = jest.fn(); + render( + + + , + {organization: creditsOrganization} + ); + await expandAll(1); + + await waitFor(() => + expect(screen.getByRole('button', {name: 'Try again'})).toHaveAttribute( + 'aria-disabled', + 'false' + ) + ); + await userEvent.click(screen.getByRole('button', {name: 'Try again'})); + expect(sendMessage).toHaveBeenCalledWith( + 'Retry the Code Changes step for JAVASCRIPT-1.' + ); + }); + + it('disables drafting the pull request again while out of credits', async () => { mockRun(withOutOfCreditsPR(makeRunThrough('code_changes', {pullRequest: 'error'}))); + mockCreditCheck('autofix_create_pr', false); render( , - {organization} + {organization: creditsOrganization} ); await expandAll(1); - expect(getButtons('Draft a pull request')).toHaveLength(0); + await expectDisabledForCredits( + screen.getByRole('button', {name: 'Draft a pull request'}) + ); }); - it('offers the CTA on the pull request step when the create failed for credits', async () => { + it('drafts the pull request again once the organization can afford it', async () => { mockRun(withOutOfCreditsPR(makeRunThrough('code_changes', {pullRequest: 'error'}))); - render(, {organization}); + mockCreditCheck('autofix_create_pr', true); + const sendMessage = jest.fn(); + render( + + + , + {organization: creditsOrganization} + ); await expandAll(1); - expect(screen.getByTestId('seer-out-of-credits')).toHaveTextContent( - "Seer couldn't open a pull request in getsentry/sentry because your organization is out of credits." + await waitFor(() => + expect( + screen.getByRole('button', {name: 'Draft a pull request'}) + ).toHaveAttribute('aria-disabled', 'false') ); + await userEvent.click(screen.getByRole('button', {name: 'Draft a pull request'})); + expect(sendMessage).toHaveBeenCalledWith('Draft a pull request for JAVASCRIPT-1.'); + }); + + it('says the pull request step failed for credits', async () => { + mockRun(withOutOfCreditsPR(makeRunThrough('code_changes', {pullRequest: 'error'}))); + render(, {organization: creditsOrganization}); + await expandAll(1); + + expect( + screen.getByText( + "Seer couldn't open a pull request in getsentry/sentry because your organization is out of credits." + ) + ).toBeInTheDocument(); expect(screen.queryByText(/This one is on us/)).not.toBeInTheDocument(); }); }); diff --git a/static/app/components/seer/markdown/embeds/components/autofix.stories.tsx b/static/app/components/seer/markdown/embeds/components/autofix.stories.tsx index 6de0a43df37b..11c96a41b3d6 100644 --- a/static/app/components/seer/markdown/embeds/components/autofix.stories.tsx +++ b/static/app/components/seer/markdown/embeds/components/autofix.stories.tsx @@ -748,10 +748,11 @@ export default Storybook.story('Autofix', story => { description={ The organization ran out of Seer credits while Autofix was writing code changes, - so the run ended with failure_reason: out_of_credits. The card keeps - what Seer wrote and the partial diff, and the out-of-credits CTA replaces “Try - again”, which would only fail again. No “Draft a pull request” is offered for - the unfinished changes. + so the run ended with failure_reason: out_of_credits. The card + keeps what Seer wrote and the partial diff, then says why it stopped. The credit + check for autofix_code_changes says the organization still can't + afford it, so “Try again” is disabled (hover it for why). No “Draft a pull + request” is offered for the unfinished changes. } run={CODE_CHANGES_STOPPED_RUN} @@ -770,9 +771,10 @@ export default Storybook.story('Autofix', story => { The code changes finished, then opening the PR failed because the organization ran out of credits (pr_creation_error_code: out_of_credits). The - Pull Request card shows the CTA in place of Seer's generic failure, and the Code - Changes card stops offering “Draft a pull request”, which would fail the same - way. + Pull Request card says so in place of Seer's generic failure. The credit check + for autofix_open_pr says the organization still can't afford it, so + the Code Changes card's “Draft a pull request” is disabled (hover it for why), + and the notice above the composer offers more credits. } run={PR_STOPPED_RUN} diff --git a/static/app/components/seer/markdown/embeds/components/autofix.tsx b/static/app/components/seer/markdown/embeds/components/autofix.tsx index 43b75c9c7d7f..6fbc67683296 100644 --- a/static/app/components/seer/markdown/embeds/components/autofix.tsx +++ b/static/app/components/seer/markdown/embeds/components/autofix.tsx @@ -48,9 +48,14 @@ import { import {defineSeerEmbed} from 'sentry/components/seer/markdown/embeds/utils'; import { getOutOfCreditsPRsMessage, + OUT_OF_CREDITS_RETRY_TOOLTIP, OUT_OF_CREDITS_STEP_MESSAGE, } from 'sentry/components/seer/outOfCredits/messages'; import {SeerOutOfCreditsCta} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; +import { + AUTOFIX_STEP_CREDIT_OPERATION, + useOutOfCreditsRetry, +} from 'sentry/components/seer/outOfCredits/useOutOfCreditsRetry'; import {t, tn} from 'sentry/locale'; import type {Group} from 'sentry/types/group'; import {defined} from 'sentry/utils/defined'; @@ -303,12 +308,25 @@ function AutofixRefContent({id, shortId, step}: AutofixRefContentProps) { const nextStep = isSingleRunAutofix(runState ?? null) ? undefined : NEXT_STEP[step]; const pendingNextStep = nextStep && !findStepSection(sections, nextStep) ? nextStep : undefined; - // A failed create also lands in `repo_pr_states`; that one should stay - // retryable, unless it failed for credits and would fail the same way again. + // A failed create also lands in `repo_pr_states`; that one should stay retryable. const canCreatePR = - step === 'code_changes' && - !hasCreatedPullRequests(runState?.repo_pr_states) && - !Object.values(runState?.repo_pr_states ?? {}).some(isOutOfCreditsPRState); + step === 'code_changes' && !hasCreatedPullRequests(runState?.repo_pr_states); + + // A step or PR that stopped for credits fails the same way until the + // organization can afford it again, so its retry waits for the credit check. + const outOfCreditsSection = + section && isOutOfCreditsSection(section) ? section : undefined; + const stepRetry = useOutOfCreditsRetry( + AUTOFIX_STEP_CREDIT_OPERATION[ + outOfCreditsSection ? getStoppedStep(outOfCreditsSection, step) : step + ], + {enabled: defined(outOfCreditsSection)} + ); + // Draft a pull request retries a create that failed for credits. The credit + // check below holds it back like any other create; this only explains why. + const prFailedForCredits = Object.values(runState?.repo_pr_states ?? {}).some( + isOutOfCreditsPRState + ); const showsNextAction = section?.status === 'completed' && (canCreatePR || !!pendingNextStep); @@ -354,9 +372,14 @@ function AutofixRefContent({id, shortId, step}: AutofixRefContentProps) { step={step} iterationOutOfCredits={iterationOutOfCredits} /> - {section?.status === 'error' && !isOutOfCreditsSection(section) && ( + {section?.status === 'error' && ( - @@ -377,6 +400,12 @@ function AutofixRefContent({id, shortId, step}: AutofixRefContentProps) { variant="primary" onClick={handleCreatePR} disabled={!canContinue} + tooltipProps={{ + title: + prFailedForCredits && !creditCheck.hasEnoughCredits + ? OUT_OF_CREDITS_RETRY_TOOLTIP + : undefined, + }} > {t('Draft a pull request')} @@ -435,21 +464,21 @@ function AutofixRefBody({ // The PR section never reads as errored, so a create or push that ran out // of credits only shows on its repo's state. const outOfCreditsPullRequests = pullRequests.filter(isOutOfCreditsPRState); - let outOfCreditsCta: ReactNode = null; + let outOfCreditsStatus: ReactNode = null; if (outOfCreditsPullRequests.length > 0) { - outOfCreditsCta = ( - + outOfCreditsStatus = ( + + {getOutOfCreditsPRsMessage(outOfCreditsPullRequests)} + ); } else if (iterationOutOfCredits) { - outOfCreditsCta = ( - + outOfCreditsStatus = ( + {OUT_OF_CREDITS_STEP_MESSAGE.pr_iteration} ); } if (!links.length) { - return outOfCreditsCta ?? {ERROR_TEXT.pr_iteration}; + return outOfCreditsStatus ?? {ERROR_TEXT.pr_iteration}; } return ( @@ -467,7 +496,7 @@ function AutofixRefBody({ ))} - {outOfCreditsCta} + {outOfCreditsStatus}
); } @@ -503,9 +532,36 @@ function AutofixRefBody({ return {ERROR_TEXT[step]}; } +/** + * The blocks of the step a section stopped in. A code_changes section also + * holds every PR iteration, and only the one that stopped is relevant. + */ +function getStoppedStepBlocks(section: AutofixSection): AutofixSection['blocks'] { + const stepStart = section.blocks.findLastIndex(block => + defined(block.message.metadata?.step) + ); + return section.blocks.slice(Math.max(stepStart, 0)); +} + +/** + * The step a section stopped in: a code_changes section can stop in a PR + * iteration. + */ +function getStoppedStep( + section: AutofixSection, + step: AutofixExplorerStep +): AutofixExplorerStep { + if (!isCodeChangesSection(section)) { + return step; + } + const firstBlock = getStoppedStepBlocks(section)[0]; + return firstBlock && isPrIterationBlock(firstBlock) ? 'pr_iteration' : 'code_changes'; +} + /** * A step that stopped for credits: what it wrote and, for code changes, the - * diff it got to, then the CTA in place of a retry that would fail again. + * diff it got to, then a line saying why it stopped. The credit gates show the + * CTA to add credits, so this has none of its own. */ function OutOfCreditsStepBody({ section, @@ -514,28 +570,16 @@ function OutOfCreditsStepBody({ section: AutofixSection; step: AutofixExplorerStep; }) { - // A code_changes section also holds every PR iteration; only the one that - // stopped is relevant here. - const stepStart = section.blocks.findLastIndex(block => - defined(block.message.metadata?.step) - ); - const stepBlocks = section.blocks.slice(Math.max(stepStart, 0)); - let stoppedStep = step; - if (isCodeChangesSection(section)) { - stoppedStep = - stepBlocks[0] && isPrIterationBlock(stepBlocks[0]) - ? 'pr_iteration' - : 'code_changes'; - } - const partialPatches = section.artifacts.findLast(isCodeChangesArtifact); const patchesByRepo = collectPatches(partialPatches ?? []); return ( - + {patchesByRepo.size > 0 && } - + + {OUT_OF_CREDITS_STEP_MESSAGE[getStoppedStep(section, step)]} + ); } diff --git a/static/app/components/seer/outOfCredits/__stories__/seerCreditCheckStub.tsx b/static/app/components/seer/outOfCredits/__stories__/seerCreditCheckStub.tsx new file mode 100644 index 000000000000..1e161d8035d1 --- /dev/null +++ b/static/app/components/seer/outOfCredits/__stories__/seerCreditCheckStub.tsx @@ -0,0 +1,58 @@ +import {useState, type ReactNode} from 'react'; +import {QueryClient, QueryClientProvider, skipToken} from '@tanstack/react-query'; + +import {OrganizationContext} from 'sentry/utils/organizationContext'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +/** + * Answers every Seer credit check with a fixed result. Other queries go to the + * API as usual. + */ +class CreditCheckQueryClient extends QueryClient { + hasEnoughCredits: boolean; + + constructor(hasEnoughCredits: boolean) { + super({defaultOptions: {queries: {retry: false}}}); + this.hasEnoughCredits = hasEnoughCredits; + } + + override defaultQueryOptions: QueryClient['defaultQueryOptions'] = options => { + const defaulted = super.defaultQueryOptions(options); + const url = String(defaulted.queryKey[0]); + if (defaulted.queryFn === skipToken || !url.includes('/seer/credit-check/')) { + return defaulted; + } + const json = {hasEnoughCredits: this.hasEnoughCredits}; + const queryFn = () => Promise.resolve({json, headers: {}}); + return {...defaulted, queryFn: queryFn as never}; + }; +} + +interface SeerCreditCheckStubProps { + children: ReactNode; + hasEnoughCredits: boolean; +} + +/** + * Turns on `seer-credit-checks` and answers every credit check with + * `hasEnoughCredits`, so a story can show what a credit-gated button does + * before and after the organization tops up. + */ +export function SeerCreditCheckStub({ + children, + hasEnoughCredits, +}: SeerCreditCheckStubProps) { + const organization = useOrganization(); + const [queryClient] = useState(() => new CreditCheckQueryClient(hasEnoughCredits)); + + return ( + + {children} + + ); +} diff --git a/static/app/components/seer/outOfCredits/messages.tsx b/static/app/components/seer/outOfCredits/messages.tsx index e4873438cfec..aed26bdb1b52 100644 --- a/static/app/components/seer/outOfCredits/messages.tsx +++ b/static/app/components/seer/outOfCredits/messages.tsx @@ -3,8 +3,9 @@ import {t} from 'sentry/locale'; import type {RepoPRState} from 'sentry/views/seerExplorer/types'; /** - * Headlines for `SeerOutOfCreditsCta` when an autofix step stopped part way. - * The CTA follows each with what the viewer can do about it. + * Inline status for an autofix step that stopped part way for credits. The + * out-of-credits CTA is shown by the credit gates, so this only says what + * happened. */ export const OUT_OF_CREDITS_STEP_MESSAGE: Record = { root_cause: t( @@ -25,6 +26,13 @@ export const OUT_OF_CREDITS_CHAT_MESSAGE = t( 'Seer stopped because your organization is out of credits.' ); +/** + * Tooltip for a retry that stays disabled until the organization can afford it. + */ +export const OUT_OF_CREDITS_RETRY_TOOLTIP = t( + 'Your organization does not have enough Seer credits for this.' +); + /** * Stands in for the backend's `pr_creation_error` on a PR that failed for * credits. A PR number means the PR already exists, so the push failed. @@ -43,8 +51,8 @@ export function getOutOfCreditsPRMessage(state: RepoPRState): string { } /** - * One headline for every PR in a run that failed for credits, so a surface - * shows a single CTA however many repos the run touched. + * One status for every PR in a run that failed for credits, however many repos + * the run touched. */ export function getOutOfCreditsPRsMessage(states: RepoPRState[]): string { if (states.length === 1) { diff --git a/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.spec.tsx b/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.spec.tsx new file mode 100644 index 000000000000..3b3178af06d4 --- /dev/null +++ b/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.spec.tsx @@ -0,0 +1,63 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {useOutOfCreditsRetry} from './useOutOfCreditsRetry'; + +describe('useOutOfCreditsRetry', () => { + const organization = OrganizationFixture({features: ['seer-credit-checks']}); + + function mockCreditCheck(hasEnoughCredits: boolean) { + return MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/seer/credit-check/`, + match: [MockApiClient.matchQuery({operation: 'autofix_code_changes'})], + body: {hasEnoughCredits}, + }); + } + + afterEach(() => { + MockApiClient.clearMockResponses(); + }); + + it('holds the retry back while the check is in flight, then disables it with a reason', async () => { + mockCreditCheck(false); + + const {result} = renderHookWithProviders( + () => useOutOfCreditsRetry('autofix_code_changes', {enabled: true}), + {organization} + ); + + expect(result.current).toEqual({disabled: true, tooltip: undefined}); + await waitFor(() => + expect(result.current.tooltip).toBe( + 'Your organization does not have enough Seer credits for this.' + ) + ); + expect(result.current.disabled).toBe(true); + }); + + it('enables the retry once the organization can afford it', async () => { + mockCreditCheck(true); + + const {result} = renderHookWithProviders( + () => useOutOfCreditsRetry('autofix_code_changes', {enabled: true}), + {organization} + ); + + await waitFor(() => + expect(result.current).toEqual({disabled: false, tooltip: undefined}) + ); + }); + + it('does not check when the retry is not on screen', () => { + const request = mockCreditCheck(false); + + const {result} = renderHookWithProviders( + () => useOutOfCreditsRetry('autofix_code_changes', {enabled: false}), + {organization} + ); + + expect(result.current).toEqual({disabled: false, tooltip: undefined}); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.tsx b/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.tsx new file mode 100644 index 000000000000..9d725d1d1e3f --- /dev/null +++ b/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.tsx @@ -0,0 +1,49 @@ +import type {AutofixExplorerStep} from 'sentry/components/events/autofix/useExplorerAutofix'; +import { + useSeerCreditCheck, + type SeerCreditOperation, +} from 'sentry/utils/seer/useSeerCreditCheck'; + +import {OUT_OF_CREDITS_RETRY_TOOLTIP} from './messages'; + +/** + * The operation that running an autofix step again spends credits on. + */ +export const AUTOFIX_STEP_CREDIT_OPERATION: Record< + AutofixExplorerStep, + SeerCreditOperation +> = { + root_cause: 'autofix_root_cause', + solution: 'autofix_solution', + code_changes: 'autofix_code_changes', + pr_iteration: 'autofix_pr_iteration', +}; + +interface UseOutOfCreditsRetryOptions { + /** + * The retry is on screen for something that stopped for credits. Nothing is + * checked otherwise. + */ + enabled: boolean; +} + +/** + * Gates the retry of a step or PR that stopped because the organization ran + * out of Seer credits. The retry stays on screen, disabled while the + * organization can't afford `operation`, and comes back once it can. + * + * Fails open like `useSeerCreditCheck`: without `seer-credit-checks` the retry + * is enabled and the backend decides. + */ +export function useOutOfCreditsRetry( + operation: SeerCreditOperation, + {enabled}: UseOutOfCreditsRetryOptions +) { + const {hasEnoughCredits, isPending} = useSeerCreditCheck(operation, {enabled}); + + return { + // Held back while the check is in flight so it doesn't flash enabled. + disabled: isPending || !hasEnoughCredits, + tooltip: hasEnoughCredits ? undefined : OUT_OF_CREDITS_RETRY_TOOLTIP, + }; +} diff --git a/static/app/views/issueList/pages/inbox/index.spec.tsx b/static/app/views/issueList/pages/inbox/index.spec.tsx index 89714a850b02..36bfdaa2acb3 100644 --- a/static/app/views/issueList/pages/inbox/index.spec.tsx +++ b/static/app/views/issueList/pages/inbox/index.spec.tsx @@ -1436,44 +1436,88 @@ describe('InboxPage', () => { ).toHaveAttribute('href', 'https://github.com/org/repository/pull/649'); }); - it('does not retry an Autofix pull request that failed for credits', async () => { - mockSuccessfulSections(); - mockIssuePreview(); - mockAutofixResponse( - ExplorerAutofixResponseFixture({ - autofix: ExplorerAutofixStateFixture({ - repo_pr_states: { - 'org/repository': AutofixRepoPRStateFixture({ - pr_creation_error: - "Your organization doesn't have enough Seer credits for this.", - pr_creation_error_code: 'out_of_credits', - pr_creation_status: 'error', - pr_id: null, - pr_number: null, - pr_url: null, - }), - }, - }), - }) - ); - - render(, { - organization: seerOrganization, - initialRouterConfig, + describe('an Autofix pull request that failed for credits', () => { + const creditsOrganization = OrganizationFixture({ + features: [...organization.features, 'seer-credit-checks'], }); - const preview = await openFixProposedPreview(); - const retryButton = await within(preview).findByRole('button', { - name: 'Retry PR in org/repository', + function mockOutOfCreditsPullRequest() { + mockSuccessfulSections(); + mockIssuePreview(); + mockAutofixResponse( + ExplorerAutofixResponseFixture({ + autofix: ExplorerAutofixStateFixture({ + repo_pr_states: { + 'org/repository': AutofixRepoPRStateFixture({ + pr_creation_error: + "Your organization doesn't have enough Seer credits for this.", + pr_creation_error_code: 'out_of_credits', + pr_creation_status: 'error', + pr_id: null, + pr_number: null, + pr_url: null, + }), + }, + }), + }) + ); + } + + function mockCreditCheck(hasEnoughCredits: boolean) { + MockApiClient.addMockResponse({ + url: `/organizations/${creditsOrganization.slug}/seer/credit-check/`, + match: [MockApiClient.matchQuery({operation: 'autofix_create_pr'})], + body: {hasEnoughCredits}, + }); + } + + it('keeps its retry, disabled, while out of credits', async () => { + mockOutOfCreditsPullRequest(); + mockCreditCheck(false); + + render(, { + organization: creditsOrganization, + initialRouterConfig, + }); + + const preview = await openFixProposedPreview(); + const retryButton = await within(preview).findByRole('button', { + name: 'Retry PR in org/repository', + }); + expect(retryButton).toHaveAttribute('aria-disabled', 'true'); + + await userEvent.hover(retryButton); + expect( + await screen.findByText( + 'Your organization does not have enough Seer credits for this.' + ) + ).toBeInTheDocument(); }); - expect(retryButton).toHaveAttribute('aria-disabled', 'true'); - await userEvent.hover(retryButton); - expect( - await screen.findByText( - "Seer couldn't open a pull request in org/repository because your organization is out of credits." - ) - ).toBeInTheDocument(); + it('enables its retry once the organization can afford it', async () => { + mockOutOfCreditsPullRequest(); + mockCreditCheck(true); + + render(, { + organization: creditsOrganization, + initialRouterConfig, + }); + + const preview = await openFixProposedPreview(); + const retryButton = await within(preview).findByRole('button', { + name: 'Retry PR in org/repository', + }); + await waitFor(() => + expect(retryButton).not.toHaveAttribute('aria-disabled', 'true') + ); + + await userEvent.hover(retryButton); + expect( + await screen.findByText( + "Seer couldn't open a pull request in org/repository because your organization is out of credits." + ) + ).toBeInTheDocument(); + }); }); it('retries a failed Autofix pull request', async () => { diff --git a/static/app/views/issueList/pages/inbox/issuePreview/issuePreviewSeerActions.tsx b/static/app/views/issueList/pages/inbox/issuePreview/issuePreviewSeerActions.tsx index cce6c3a1dde4..9325b5008a02 100644 --- a/static/app/views/issueList/pages/inbox/issuePreview/issuePreviewSeerActions.tsx +++ b/static/app/views/issueList/pages/inbox/issuePreview/issuePreviewSeerActions.tsx @@ -31,12 +31,13 @@ import { import {useCodingAgents} from 'sentry/components/events/autofix/v3/useCodingAgents'; import {Placeholder} from 'sentry/components/placeholder'; import {getOutOfCreditsPRMessage} from 'sentry/components/seer/outOfCredits/messages'; +import {useOutOfCreditsRetry} from 'sentry/components/seer/outOfCredits/useOutOfCreditsRetry'; import {PluginIcon} from 'sentry/icons/pluginIcon'; import {t} from 'sentry/locale'; import type {Group} from 'sentry/types/group'; import {defined} from 'sentry/utils/defined'; import {useOrganization} from 'sentry/utils/useOrganization'; -import {isOutOfCreditsPRState} from 'sentry/views/seerExplorer/types'; +import {isOutOfCreditsPRState, type RepoPRState} from 'sentry/views/seerExplorer/types'; import { hidesCodingAgentResultLink, @@ -237,6 +238,56 @@ function RunSeerActionButton({ ); } +interface RetryPullRequestButtonProps extends Pick< + SeerActionButtonProps, + 'autofix' | 'disabled' | 'group' | 'onContinueInSeer' | 'variant' +> { + failedPullRequest: RepoPRState; + runId: number; +} + +/** + * A PR that failed for credits fails the same way until the organization can + * afford it, so its retry stays on screen, disabled, rather than letting + * another action step in as the next one. + */ +function RetryPullRequestButton({ + autofix, + disabled, + failedPullRequest, + group, + onContinueInSeer, + runId, + variant, +}: RetryPullRequestButtonProps) { + const isOutOfCredits = isOutOfCreditsPRState(failedPullRequest); + const outOfCreditsRetry = useOutOfCreditsRetry('autofix_create_pr', { + enabled: isOutOfCredits, + }); + + return ( + autofix.createPR(runId, failedPullRequest.repo_name)} + analyticsAction="create_pr" + analyticsEventKey="issue_inbox.create_pr_clicked" + analyticsEventName="Issue Inbox: Create PR Setup Clicked" + analyticsParams={{is_retry: true}} + autofix={autofix} + disabled={disabled || outOfCreditsRetry.disabled} + group={group} + icon={} + label={t('Retry PR in %s', failedPullRequest.repo_name)} + onContinueInSeer={onContinueInSeer} + tooltip={ + isOutOfCredits + ? (outOfCreditsRetry.tooltip ?? getOutOfCreditsPRMessage(failedPullRequest)) + : failedPullRequest.pr_creation_error + } + variant={variant} + /> + ); +} + function SeerActionButton({ autofix, creditsDisabled, @@ -301,27 +352,14 @@ function SeerActionButton({ ); if (failedPullRequest) { - // Retrying fails the same way until someone adds credits. The button stays, - // disabled, so nothing else steps in as the next action. - const isOutOfCredits = isOutOfCreditsPRState(failedPullRequest); return ( - autofix.createPR(runState.run_id, failedPullRequest.repo_name)} - analyticsAction="create_pr" - analyticsEventKey="issue_inbox.create_pr_clicked" - analyticsEventName="Issue Inbox: Create PR Setup Clicked" - analyticsParams={{is_retry: true}} + } - label={t('Retry PR in %s', failedPullRequest.repo_name)} onContinueInSeer={onContinueInSeer} - tooltip={ - isOutOfCredits - ? getOutOfCreditsPRMessage(failedPullRequest) - : failedPullRequest.pr_creation_error - } + runId={runState.run_id} variant={variant} /> ); diff --git a/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx b/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx index 8d1726cde5f2..54bc441a4e7e 100644 --- a/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx +++ b/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx @@ -417,7 +417,7 @@ const AWAITING_ANSWER_SESSION: Session = { }; // Seer ran out of credits after two tool calls. It keeps the blocks it wrote and appends a -// plain-text error block, which the chat swaps for the out-of-credits CTA. +// plain-text error block, which the chat swaps for a line saying why it stopped. const OUT_OF_CREDITS_SESSION: Session = { status: 'error', failure_reason: 'out_of_credits', @@ -570,8 +570,9 @@ export default Storybook.story('ChatWindow', story => {

The organization ran out of Seer credits mid-response. The run ends with{' '} failure_reason: out_of_credits. The reasoning and tool calls Seer - made before it stopped stay in the thinking block, and the out-of-credits CTA - replaces Seer's plain-text error message. + made before it stopped stay in the thinking block, and a muted line saying Seer + stopped for credits replaces Seer's plain-text error message. There is no CTA in + the transcript: the out-of-credits alert above the composer covers it.

diff --git a/static/app/views/seerExplorer/components/chat/responseGroup.tsx b/static/app/views/seerExplorer/components/chat/responseGroup.tsx index a8834dd93753..8d2e1a8d821b 100644 --- a/static/app/views/seerExplorer/components/chat/responseGroup.tsx +++ b/static/app/views/seerExplorer/components/chat/responseGroup.tsx @@ -2,12 +2,12 @@ import {Fragment, memo, useState} from 'react'; import styled from '@emotion/styled'; import {motion} from 'framer-motion'; -import {MessageRow, ThinkingBlock} from '@sentry/scraps/chat'; +import {AssistantMessage, MessageRow, ThinkingBlock} from '@sentry/scraps/chat'; import {Container} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; import {SeerMarkdown} from 'sentry/components/seer/markdown'; import {OUT_OF_CREDITS_CHAT_MESSAGE} from 'sentry/components/seer/outOfCredits/messages'; -import {SeerOutOfCreditsCta} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; import {t} from 'sentry/locale'; import {callRecordLabel, visibleCallRecords} from 'sentry/views/seerExplorer/callRecords'; import { @@ -275,7 +275,7 @@ export const ResponseGroup = memo(function ResponseGroup({ ) : null} - {/* Seer's plain-text error block gives way to the CTA. */} + {/* Seer's plain-text error block gives way to a line saying why it stopped. */} {settledAnswer && !(showOutOfCredits && isSeerErrorBlock(settledAnswer)) ? ( - - - + + + {OUT_OF_CREDITS_CHAT_MESSAGE} + ); } diff --git a/static/app/views/seerExplorer/components/prLinksBar.spec.tsx b/static/app/views/seerExplorer/components/prLinksBar.spec.tsx index 096985f4c1a7..89dbed46f3c1 100644 --- a/static/app/views/seerExplorer/components/prLinksBar.spec.tsx +++ b/static/app/views/seerExplorer/components/prLinksBar.spec.tsx @@ -1,7 +1,5 @@ import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; -import type {SeerOutOfCreditsCtaProps} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; -import {registerOverride} from 'sentry/overrideRegistry'; import type {RepoPRState} from 'sentry/views/seerExplorer/types'; import {PRLinksBar} from './prLinksBar'; @@ -132,68 +130,29 @@ describe('PRLinksBar', () => { expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); - describe('out of credits', () => { - // getsentry fills in the CTA; this stand-in shows the headline it was given. - function StubOutOfCreditsCta({message}: SeerOutOfCreditsCtaProps) { - return
{message}
; - } - - beforeEach(() => { - registerOverride('component:seer-out-of-credits-cta', () => StubOutOfCreditsCta); - }); - - it('explains a PR that failed for credits and shows the CTA', async () => { - render( - - ); - - const message = - "Seer couldn't open a pull request in acme/web because your organization is out of credits."; - expect(screen.getByTestId('seer-out-of-credits')).toHaveTextContent(message); - - await userEvent.hover(screen.getByRole('button', {name: REPO})); - expect(await screen.findAllByText(message)).toHaveLength(2); - }); - - it('shows one CTA for several PRs that failed for credits', () => { - const failed = { - pr_creation_status: 'error', - pr_creation_error_code: 'out_of_credits', - } as const; - render( - - ); - - expect(screen.getByTestId('seer-out-of-credits')).toHaveTextContent( - "Seer couldn't finish 2 pull requests because your organization is out of credits." - ); - }); - - it('shows no CTA for other failures', () => { - render( - - ); - - expect(screen.queryByTestId('seer-out-of-credits')).not.toBeInTheDocument(); - }); + it('explains a PR that failed for credits in its tooltip', async () => { + render( + + ); + + await userEvent.hover(screen.getByRole('button', {name: REPO})); + expect( + await screen.findByText( + "Seer couldn't open a pull request in acme/web because your organization is out of credits." + ) + ).toBeInTheDocument(); + expect( + screen.queryByText("Your organization doesn't have enough Seer credits.") + ).not.toBeInTheDocument(); }); }); diff --git a/static/app/views/seerExplorer/components/prLinksBar.stories.tsx b/static/app/views/seerExplorer/components/prLinksBar.stories.tsx index 81d22349b245..77c90388d696 100644 --- a/static/app/views/seerExplorer/components/prLinksBar.stories.tsx +++ b/static/app/views/seerExplorer/components/prLinksBar.stories.tsx @@ -123,9 +123,9 @@ export default Storybook.story('PRLinksBar', story => { The PR could not be opened because the organization ran out of Seer credits. The - button's tooltip says so instead of the raw backend error, and the out-of-credits - CTA sits under the bar, since the failure toast is gone by the time anyone reads - it. + button has no retry; hover it and its tooltip says the PR failed for credits + instead of the raw backend error. There is no CTA under the bar: the + out-of-credits alert above the chat composer covers it. diff --git a/static/app/views/seerExplorer/components/prLinksBar.tsx b/static/app/views/seerExplorer/components/prLinksBar.tsx index 094a3369db8d..e1472a43d6bc 100644 --- a/static/app/views/seerExplorer/components/prLinksBar.tsx +++ b/static/app/views/seerExplorer/components/prLinksBar.tsx @@ -2,14 +2,10 @@ import {IconPullRequest} from '@sentry/icons/pullRequest'; import {IconWarning} from '@sentry/icons/warning'; import {Button, LinkButton} from '@sentry/scraps/button'; -import {Flex, Stack} from '@sentry/scraps/layout'; +import {Flex} from '@sentry/scraps/layout'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import { - getOutOfCreditsPRMessage, - getOutOfCreditsPRsMessage, -} from 'sentry/components/seer/outOfCredits/messages'; -import {SeerOutOfCreditsCta} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; +import {getOutOfCreditsPRMessage} from 'sentry/components/seer/outOfCredits/messages'; import {t} from 'sentry/locale'; import {isValidUrl} from 'sentry/utils/string/isValidUrl'; import {isOutOfCreditsPRState, type RepoPRState} from 'sentry/views/seerExplorer/types'; @@ -27,20 +23,12 @@ export function PRLinksBar({repoPRStates}: PRLinksBarProps) { return null; } - // The failure toast is gone by the time anyone reads it, so the CTA lives here. - const outOfCreditsStates = states.filter(isOutOfCreditsPRState); - return ( - - - {states.map(state => ( - - ))} - - {outOfCreditsStates.length > 0 && ( - - )} - + + {states.map(state => ( + + ))} + ); } diff --git a/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx b/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx index 1eeb109da00c..7c0c805b999f 100644 --- a/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx @@ -1283,15 +1283,6 @@ describe('SeerExplorerContent', () => { }); describe('Run stopped for credits', () => { - // getsentry fills in the CTA; this stand-in shows the headline it was given. - function StubOutOfCreditsCta({message}: SeerOutOfCreditsCtaProps) { - return
{message}
; - } - - beforeEach(() => { - registerOverride('component:seer-out-of-credits-cta', () => StubOutOfCreditsCta); - }); - const stoppedBlocks = [ { id: 'user-1', @@ -1331,7 +1322,7 @@ describe('SeerExplorerContent', () => { ); } - it("replaces Seer's error block with the CTA and keeps the partial response", async () => { + it("replaces Seer's error block with why it stopped and keeps the partial response", async () => { jest.spyOn(useSeerExplorerModule, 'useSeerExplorer').mockReturnValue({ ...defaultHookReturn, runId: 123, @@ -1345,9 +1336,11 @@ describe('SeerExplorerContent', () => { renderContent(); - expect(await screen.findByTestId('seer-out-of-credits')).toHaveTextContent( - 'Seer stopped because your organization is out of credits.' - ); + expect( + await screen.findByText( + 'Seer stopped because your organization is out of credits.' + ) + ).toBeInTheDocument(); expect(screen.queryByText(/so I stopped here/)).not.toBeInTheDocument(); expect(screen.queryByText('Response timed out.')).not.toBeInTheDocument(); // The reasoning before it stopped stays in the thinking block. @@ -1372,7 +1365,9 @@ describe('SeerExplorerContent', () => { renderContent(); expect(await screen.findByText(/so I stopped here/)).toBeInTheDocument(); - expect(screen.queryByTestId('seer-out-of-credits')).not.toBeInTheDocument(); + expect( + screen.queryByText('Seer stopped because your organization is out of credits.') + ).not.toBeInTheDocument(); }); }); From e46f8279dbaf31ddb65a16ab751ad6c9043ecd6f Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Thu, 5 Nov 2026 15:48:08 -0500 Subject: [PATCH 3/8] feat(seer): Add RootCauseCard and SolutionCard out-of-credits stories Both cards gained an out-of-credits state with no story. Each story file shows the card stopped for credits with Re-run disabled, the same run after a top-up, and a run that stopped before Seer wrote anything. Co-Authored-By: Claude Opus 5.5 --- .../autofix/v3/rootCauseCard.stories.tsx | 126 ++++++++++++++++++ .../autofix/v3/solutionCard.stories.tsx | 125 +++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 static/app/components/events/autofix/v3/rootCauseCard.stories.tsx create mode 100644 static/app/components/events/autofix/v3/solutionCard.stories.tsx diff --git a/static/app/components/events/autofix/v3/rootCauseCard.stories.tsx b/static/app/components/events/autofix/v3/rootCauseCard.stories.tsx new file mode 100644 index 000000000000..d03bef5c6cb1 --- /dev/null +++ b/static/app/components/events/autofix/v3/rootCauseCard.stories.tsx @@ -0,0 +1,126 @@ +import {Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import type { + AutofixSection, + useExplorerAutofix, +} from 'sentry/components/events/autofix/useExplorerAutofix'; +import {SeerCreditCheckStub} from 'sentry/components/seer/outOfCredits/__stories__/seerCreditCheckStub'; +import * as Storybook from 'sentry/stories'; + +import {RootCauseCard} from './rootCauseCard'; + +const noop = () => {}; + +const OUT_OF_CREDITS_AUTOFIX: ReturnType = { + runState: { + run_id: 123, + blocks: [], + status: 'error', + failure_reason: 'out_of_credits', + updated_at: '2026-07-20T00:00:00Z', + }, + autofixFormatted: null, + isLoading: false, + isWaitingForRun: false, + isPolling: false, + isProcessing: false, + // None of these are invoked by the static examples below. + startStep: () => Promise.resolve({runId: 0, isSingleRun: false}), + createPR: () => Promise.resolve(), + reset: noop, + triggerCodingAgentHandoff: () => Promise.resolve(), + codingAgentErrors: [], + dismissCodingAgentError: noop, + warnings: [], +}; + +// The block Seer appends when a run stops for credits. The card leaves it out +// and says why the step stopped instead. +const SEER_OUT_OF_CREDITS_BLOCK: AutofixSection['blocks'][number] = { + id: 'error-1', + timestamp: '2026-07-20T00:00:00Z', + message: { + role: 'assistant', + content: + "Your organization doesn't have enough Seer credits for this, so I stopped here. Add credits to continue.", + }, +}; + +const PARTIAL_BLOCK: AutofixSection['blocks'][number] = { + id: 'block-root-cause', + timestamp: '2026-07-20T00:00:00Z', + message: { + role: 'assistant', + content: + '`CartService.total()` calls `reduce` without an initial value, so an empty cart throws `TypeError`. Next I was going to check which callers can pass an empty cart.', + metadata: {step: 'root_cause'}, + }, +}; + +function makeOutOfCreditsSection(blocks: AutofixSection['blocks']): AutofixSection { + return { + step: 'root_cause', + status: 'error', + failureReason: 'out_of_credits', + blocks: [...blocks, SEER_OUT_OF_CREDITS_BLOCK], + artifacts: [], + }; +} + +function StoppedForCredits({ + blocks = [PARTIAL_BLOCK], +}: { + blocks?: AutofixSection['blocks']; +}) { + return ( + + ); +} + +export default Storybook.story('RootCauseCard', story => { + story('Stopped for credits', () => ( + + + The organization ran out of Seer credits while Seer was finding the root cause. + The run ends with failure_reason: out_of_credits. The card keeps what + Seer wrote so far, then says why the step stopped. The credit check for{' '} + autofix_root_cause says the organization still can't afford it, so + Re-run and the header's Re-run step are disabled (hover Re-run for why). The + out-of-credits CTA is not here: the alert at the top of the Autofix tab and drawer + covers it. + + + + + + )); + + story('Stopped for credits, after a top-up', () => ( + + + The same stopped run once the organization has added credits: the credit check now + says it can afford autofix_root_cause, so Re-run and Re-run step are + enabled again without reloading. + + + + + + )); + + story('Stopped before writing anything', () => ( + + + When the run stops before Seer writes anything, the card only says why it stopped. + + + + + + )); +}); diff --git a/static/app/components/events/autofix/v3/solutionCard.stories.tsx b/static/app/components/events/autofix/v3/solutionCard.stories.tsx new file mode 100644 index 000000000000..1968387fa6c8 --- /dev/null +++ b/static/app/components/events/autofix/v3/solutionCard.stories.tsx @@ -0,0 +1,125 @@ +import {Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import type { + AutofixSection, + useExplorerAutofix, +} from 'sentry/components/events/autofix/useExplorerAutofix'; +import {SeerCreditCheckStub} from 'sentry/components/seer/outOfCredits/__stories__/seerCreditCheckStub'; +import * as Storybook from 'sentry/stories'; + +import {SolutionCard} from './solutionCard'; + +const noop = () => {}; + +const OUT_OF_CREDITS_AUTOFIX: ReturnType = { + runState: { + run_id: 123, + blocks: [], + status: 'error', + failure_reason: 'out_of_credits', + updated_at: '2026-07-20T00:00:00Z', + }, + autofixFormatted: null, + isLoading: false, + isWaitingForRun: false, + isPolling: false, + isProcessing: false, + // None of these are invoked by the static examples below. + startStep: () => Promise.resolve({runId: 0, isSingleRun: false}), + createPR: () => Promise.resolve(), + reset: noop, + triggerCodingAgentHandoff: () => Promise.resolve(), + codingAgentErrors: [], + dismissCodingAgentError: noop, + warnings: [], +}; + +// The block Seer appends when a run stops for credits. The card leaves it out +// and says why the step stopped instead. +const SEER_OUT_OF_CREDITS_BLOCK: AutofixSection['blocks'][number] = { + id: 'error-1', + timestamp: '2026-07-20T00:00:00Z', + message: { + role: 'assistant', + content: + "Your organization doesn't have enough Seer credits for this, so I stopped here. Add credits to continue.", + }, +}; + +const PARTIAL_BLOCK: AutofixSection['blocks'][number] = { + id: 'block-solution', + timestamp: '2026-07-20T00:00:00Z', + message: { + role: 'assistant', + content: + 'Pass `0` as the initial value to `reduce` in `CartService.total()` so an empty cart totals to zero. Next I was going to plan a regression test for the empty cart.', + metadata: {step: 'solution'}, + }, +}; + +function makeOutOfCreditsSection(blocks: AutofixSection['blocks']): AutofixSection { + return { + step: 'solution', + status: 'error', + failureReason: 'out_of_credits', + blocks: [...blocks, SEER_OUT_OF_CREDITS_BLOCK], + artifacts: [], + }; +} + +function StoppedForCredits({ + blocks = [PARTIAL_BLOCK], +}: { + blocks?: AutofixSection['blocks']; +}) { + return ( + + ); +} + +export default Storybook.story('SolutionCard', story => { + story('Stopped for credits', () => ( + + + The organization ran out of Seer credits while Seer was writing the plan. The run + ends with failure_reason: out_of_credits. The card keeps what Seer + wrote so far, then says why the step stopped. The credit check for{' '} + autofix_solution says the organization still can't afford it, so + Re-run and the header's Re-run step are disabled (hover Re-run for why). The + out-of-credits CTA is not here: the alert at the top of the Autofix tab and drawer + covers it. + + + + + + )); + + story('Stopped for credits, after a top-up', () => ( + + + The same stopped run once the organization has added credits: the credit check now + says it can afford autofix_solution, so Re-run and Re-run step are + enabled again without reloading. + + + + + + )); + + story('Stopped before writing anything', () => ( + + + When the run stops before Seer writes anything, the card only says why it stopped. + + + + + + )); +}); From dc60d9d432ae9e022aca495ddc8fde048efe8a3a Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Thu, 5 Nov 2026 15:48:15 -0500 Subject: [PATCH 4/8] test(seer): Turn on token-billed Seer where credit checks are tested Credit checks also need token-billed-seer-enabled as of #129545, so the tests, stories and SeerCreditCheckStub that turn on seer-credit-checks turn it on too. Co-Authored-By: Claude Opus 5.5 --- .../events/autofix/v3/autofixCards.spec.tsx | 10 ++++++++-- .../seer/markdown/embeds/components/autofix.spec.tsx | 2 +- .../outOfCredits/__stories__/seerCreditCheckStub.tsx | 12 ++++++++---- .../seer/outOfCredits/useOutOfCreditsRetry.spec.tsx | 4 +++- .../app/views/issueList/pages/inbox/index.spec.tsx | 6 +++++- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/static/app/components/events/autofix/v3/autofixCards.spec.tsx b/static/app/components/events/autofix/v3/autofixCards.spec.tsx index 88f961cb2971..033b9ef128a2 100644 --- a/static/app/components/events/autofix/v3/autofixCards.spec.tsx +++ b/static/app/components/events/autofix/v3/autofixCards.spec.tsx @@ -2704,9 +2704,15 @@ describe('ArtifactCard', () => { }); describe('out of credits', () => { - const organization = OrganizationFixture({features: ['seer-credit-checks']}); + const organization = OrganizationFixture({ + features: ['seer-credit-checks', 'token-billed-seer-enabled'], + }); const manualPrIterationCreditsOrganization = OrganizationFixture({ - features: ['seer-credit-checks', 'autofix-pr-iteration-manual'], + features: [ + 'seer-credit-checks', + 'token-billed-seer-enabled', + 'autofix-pr-iteration-manual', + ], }); const retryTooltip = 'Your organization does not have enough Seer credits for this.'; diff --git a/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx b/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx index e1894124b193..d4860c8c151b 100644 --- a/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx @@ -496,7 +496,7 @@ describe('AutofixRef embed', () => { describe('out of credits', () => { const creditsOrganization = OrganizationFixture({ slug: 'org-slug', - features: ['seer-credit-checks'], + features: ['seer-credit-checks', 'token-billed-seer-enabled'], }); const retryTooltip = 'Your organization does not have enough Seer credits for this.'; diff --git a/static/app/components/seer/outOfCredits/__stories__/seerCreditCheckStub.tsx b/static/app/components/seer/outOfCredits/__stories__/seerCreditCheckStub.tsx index 1e161d8035d1..9ede540e6cfd 100644 --- a/static/app/components/seer/outOfCredits/__stories__/seerCreditCheckStub.tsx +++ b/static/app/components/seer/outOfCredits/__stories__/seerCreditCheckStub.tsx @@ -34,9 +34,9 @@ interface SeerCreditCheckStubProps { } /** - * Turns on `seer-credit-checks` and answers every credit check with - * `hasEnoughCredits`, so a story can show what a credit-gated button does - * before and after the organization tops up. + * Turns on `seer-credit-checks` and `token-billed-seer-enabled`, and answers + * every credit check with `hasEnoughCredits`, so a story can show what a + * credit-gated button does before and after the organization tops up. */ export function SeerCreditCheckStub({ children, @@ -49,7 +49,11 @@ export function SeerCreditCheckStub({ {children} diff --git a/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.spec.tsx b/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.spec.tsx index 3b3178af06d4..8c418c21ab79 100644 --- a/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.spec.tsx +++ b/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.spec.tsx @@ -5,7 +5,9 @@ import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary' import {useOutOfCreditsRetry} from './useOutOfCreditsRetry'; describe('useOutOfCreditsRetry', () => { - const organization = OrganizationFixture({features: ['seer-credit-checks']}); + const organization = OrganizationFixture({ + features: ['seer-credit-checks', 'token-billed-seer-enabled'], + }); function mockCreditCheck(hasEnoughCredits: boolean) { return MockApiClient.addMockResponse({ diff --git a/static/app/views/issueList/pages/inbox/index.spec.tsx b/static/app/views/issueList/pages/inbox/index.spec.tsx index 36bfdaa2acb3..2b96080740d1 100644 --- a/static/app/views/issueList/pages/inbox/index.spec.tsx +++ b/static/app/views/issueList/pages/inbox/index.spec.tsx @@ -1438,7 +1438,11 @@ describe('InboxPage', () => { describe('an Autofix pull request that failed for credits', () => { const creditsOrganization = OrganizationFixture({ - features: [...organization.features, 'seer-credit-checks'], + features: [ + ...organization.features, + 'seer-credit-checks', + 'token-billed-seer-enabled', + ], }); function mockOutOfCreditsPullRequest() { From d5d75133cde27a90b1fc04d83fc69eab57502d0c Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Thu, 5 Nov 2026 16:49:06 -0500 Subject: [PATCH 5/8] fix(seer): Use autofix_open_pr for out-of-credits PR retries The backend's SeerOperation calls the PR step autofix_open_pr and rejects autofix_create_pr, so the PR retry gates always failed open. Co-Authored-By: Claude Opus 5.5 --- .../components/events/autofix/v3/autofixCards.spec.tsx | 4 ++-- .../events/autofix/v3/pullRequestsCard.stories.tsx | 8 ++++---- .../app/components/events/autofix/v3/pullRequestsCard.tsx | 2 +- .../seer/markdown/embeds/components/autofix.spec.tsx | 4 ++-- static/app/views/issueList/pages/inbox/index.spec.tsx | 2 +- .../pages/inbox/issuePreview/issuePreviewSeerActions.tsx | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/static/app/components/events/autofix/v3/autofixCards.spec.tsx b/static/app/components/events/autofix/v3/autofixCards.spec.tsx index 033b9ef128a2..79f24c38b313 100644 --- a/static/app/components/events/autofix/v3/autofixCards.spec.tsx +++ b/static/app/components/events/autofix/v3/autofixCards.spec.tsx @@ -3007,7 +3007,7 @@ describe('out of credits', () => { ]); it('says why and disables its retry while out of credits', async () => { - mockCreditCheck('autofix_create_pr', false); + mockCreditCheck('autofix_open_pr', false); render(, { organization, }); @@ -3029,7 +3029,7 @@ describe('out of credits', () => { }); it('retries it once the organization can afford it', async () => { - mockCreditCheck('autofix_create_pr', true); + mockCreditCheck('autofix_open_pr', true); const createPR = jest.fn(); render( { Creating the PR failed because the organization ran out of Seer credits ( pr_creation_error_code: out_of_credits). A line under the PRs says - so, and the credit check for autofix_create_pr says the - organization still can't afford it, so its “Retry PR” is disabled (hover it for - why). The out-of-credits CTA is not here: the alert at the top of the Autofix - tab and drawer covers it. + so, and the credit check for autofix_open_pr says the organization + still can't afford it, so its “Retry PR” is disabled (hover it for why). The + out-of-credits CTA is not here: the alert at the top of the Autofix tab and + drawer covers it. diff --git a/static/app/components/events/autofix/v3/pullRequestsCard.tsx b/static/app/components/events/autofix/v3/pullRequestsCard.tsx index ea0963c80bf4..423cc49cb972 100644 --- a/static/app/components/events/autofix/v3/pullRequestsCard.tsx +++ b/static/app/components/events/autofix/v3/pullRequestsCard.tsx @@ -49,7 +49,7 @@ export function PullRequestsCard({autofix, section}: PullRequestsCardProps) { const outOfCreditsPullRequests = artifact?.filter(isOutOfCreditsPRState) ?? []; // Their "Retry PR" fails the same way until the organization can afford it. - const outOfCreditsRetry = useOutOfCreditsRetry('autofix_create_pr', { + const outOfCreditsRetry = useOutOfCreditsRetry('autofix_open_pr', { enabled: defined(runId) && outOfCreditsPullRequests.length > 0, }); diff --git a/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx b/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx index d4860c8c151b..e2daa5256d00 100644 --- a/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx @@ -609,7 +609,7 @@ describe('AutofixRef embed', () => { it('disables drafting the pull request again while out of credits', async () => { mockRun(withOutOfCreditsPR(makeRunThrough('code_changes', {pullRequest: 'error'}))); - mockCreditCheck('autofix_create_pr', false); + mockCreditCheck('autofix_open_pr', false); render( @@ -625,7 +625,7 @@ describe('AutofixRef embed', () => { it('drafts the pull request again once the organization can afford it', async () => { mockRun(withOutOfCreditsPR(makeRunThrough('code_changes', {pullRequest: 'error'}))); - mockCreditCheck('autofix_create_pr', true); + mockCreditCheck('autofix_open_pr', true); const sendMessage = jest.fn(); render( diff --git a/static/app/views/issueList/pages/inbox/index.spec.tsx b/static/app/views/issueList/pages/inbox/index.spec.tsx index 2b96080740d1..a399a3095516 100644 --- a/static/app/views/issueList/pages/inbox/index.spec.tsx +++ b/static/app/views/issueList/pages/inbox/index.spec.tsx @@ -1470,7 +1470,7 @@ describe('InboxPage', () => { function mockCreditCheck(hasEnoughCredits: boolean) { MockApiClient.addMockResponse({ url: `/organizations/${creditsOrganization.slug}/seer/credit-check/`, - match: [MockApiClient.matchQuery({operation: 'autofix_create_pr'})], + match: [MockApiClient.matchQuery({operation: 'autofix_open_pr'})], body: {hasEnoughCredits}, }); } diff --git a/static/app/views/issueList/pages/inbox/issuePreview/issuePreviewSeerActions.tsx b/static/app/views/issueList/pages/inbox/issuePreview/issuePreviewSeerActions.tsx index 9325b5008a02..a8be5eab6acf 100644 --- a/static/app/views/issueList/pages/inbox/issuePreview/issuePreviewSeerActions.tsx +++ b/static/app/views/issueList/pages/inbox/issuePreview/issuePreviewSeerActions.tsx @@ -261,7 +261,7 @@ function RetryPullRequestButton({ variant, }: RetryPullRequestButtonProps) { const isOutOfCredits = isOutOfCreditsPRState(failedPullRequest); - const outOfCreditsRetry = useOutOfCreditsRetry('autofix_create_pr', { + const outOfCreditsRetry = useOutOfCreditsRetry('autofix_open_pr', { enabled: isOutOfCredits, }); From 945169a190afb660cb623c04d4e0ca510da394a2 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Thu, 5 Nov 2026 16:49:14 -0500 Subject: [PATCH 6/8] fix(seer): Break the import cycle between the autofix embed and markdown The autofix embed rendered a stopped step's text with ArtifactPartialDetails, whose SeerMarkdown imports the embeds, so knip --cycles failed. The embed now renders that text with its own markdown, like the rest of its steps. Co-Authored-By: Claude Opus 5.5 --- .../markdown/embeds/components/autofix.tsx | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/static/app/components/seer/markdown/embeds/components/autofix.tsx b/static/app/components/seer/markdown/embeds/components/autofix.tsx index 6fbc67683296..eb3fea254f4c 100644 --- a/static/app/components/seer/markdown/embeds/components/autofix.tsx +++ b/static/app/components/seer/markdown/embeds/components/autofix.tsx @@ -39,7 +39,6 @@ import { } from 'sentry/components/events/autofix/useExplorerAutofix'; import {useRefreshAutofixProgressQueries} from 'sentry/components/events/autofix/useRefreshAutofixProgressQueries'; import {ArtifactDetails} from 'sentry/components/events/autofix/v3/artifactDetails'; -import {ArtifactPartialDetails} from 'sentry/components/events/autofix/v3/artifactLoadingDetails'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import { useAutofixChat, @@ -59,9 +58,10 @@ import { import {t, tn} from 'sentry/locale'; import type {Group} from 'sentry/types/group'; import {defined} from 'sentry/utils/defined'; +import {useAutoScroll} from 'sentry/utils/useAutoScroll'; import {useOrganization} from 'sentry/utils/useOrganization'; import {FileDiffViewer} from 'sentry/views/seerExplorer/components/fileDiffViewer'; -import {isOutOfCreditsPRState} from 'sentry/views/seerExplorer/types'; +import {isOutOfCreditsPRState, isSeerErrorBlock} from 'sentry/views/seerExplorer/types'; import {resourceLinkMarkdown} from './resourceLink'; import {SeerEmbedBlock} from './seerEmbedBlock'; @@ -558,6 +558,51 @@ function getStoppedStep( return firstBlock && isPrIterationBlock(firstBlock) ? 'pr_iteration' : 'code_changes'; } +function hasText(content: string | null | undefined): content is string { + const trimmed = content?.trim(); + return !!trimmed && trimmed !== '.'; +} + +/** + * What a stopped step wrote, without Seer's own error block, since the line + * below says why it stopped. It renders with this embed's markdown rather than + * `ArtifactPartialDetails`, whose `SeerMarkdown` renders embeds and so would + * import this file back. + */ +function StoppedStepText({blocks}: {blocks: AutofixSection['blocks']}) { + const {containerRef, onScrollHandler} = useAutoScroll({key: blocks}); + const texts = blocks.flatMap(block => { + if (block.message.role === 'user' || isSeerErrorBlock(block)) { + return []; + } + const {content, thinking_content} = block.message; + if (hasText(content) && content !== 'Thinking...') { + return [content]; + } + return hasText(thinking_content) ? [thinking_content] : []; + }); + + if (texts.length === 0) { + return null; + } + + return ( + + + {texts.map((text, index) => ( + + ))} + + + ); +} + /** * A step that stopped for credits: what it wrote and, for code changes, the * diff it got to, then a line saying why it stopped. The credit gates show the @@ -575,7 +620,7 @@ function OutOfCreditsStepBody({ return ( - + {patchesByRepo.size > 0 && } {OUT_OF_CREDITS_STEP_MESSAGE[getStoppedStep(section, step)]} From c973b0cadbeac1d968d8c4b84d1d1ef9381eebca Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 10 Nov 2026 16:33:19 -0500 Subject: [PATCH 7/8] ref(seer): Check out-of-credits retries through the autofix credit map #129565 landed `AUTOFIX_CREDIT_OPERATIONS` and `useAutofixCreditCheck`, which map each autofix action to the operation the credit check prices. The retries here kept their own copy of that map. `useOutOfCreditsRetry` now takes the autofix action and checks it through `useAutofixCreditCheck`, so both gates read one map. The retry also waits on `hasCheckedSinceMount` instead of `isPending`, as the #129565 gates do. A cached answer from an earlier mount no longer enables a retry before this mount's recheck answers. SeerExplorerContent reuses `isRunOutOfCredits`, which #130463 added, for the transcript's out-of-credits line. The chat window story for a run that stopped for credits now shows the composer's out-of-credits alert, which that recheck brings up. The chat window stories answered the credit usage meter's wallet request with an empty list when they had no wallet fixture, so the meter crashed and took every story in the file with it. Those requests now fail, as they do for an org without token-billed Seer, and a story without a wallet is viewed by a member without billing. Co-Authored-By: Claude Opus 5.5 --- .../events/autofix/autofixCredits.tsx | 5 +- .../events/autofix/v3/codeChangesCard.tsx | 12 ++--- .../events/autofix/v3/pullRequestsCard.tsx | 2 +- .../events/autofix/v3/rootCauseCard.tsx | 2 +- .../events/autofix/v3/solutionCard.tsx | 2 +- .../markdown/embeds/components/autofix.tsx | 9 +--- .../useOutOfCreditsRetry.spec.tsx | 54 +++++++++++++++++-- .../outOfCredits/useOutOfCreditsRetry.tsx | 33 ++++-------- .../issuePreview/issuePreviewSeerActions.tsx | 2 +- .../components/chat/chatWindow.stories.tsx | 23 +++++--- .../components/seerExplorerContent.tsx | 2 +- 11 files changed, 93 insertions(+), 53 deletions(-) diff --git a/static/app/components/events/autofix/autofixCredits.tsx b/static/app/components/events/autofix/autofixCredits.tsx index 1fe60f123b1e..9bbd18a014ca 100644 --- a/static/app/components/events/autofix/autofixCredits.tsx +++ b/static/app/components/events/autofix/autofixCredits.tsx @@ -17,7 +17,10 @@ import { type ExplorerAutofixState, } from './useExplorerAutofix'; -type AutofixAction = AutofixNextStep['action']; +/** + * A billable autofix action: a step, opening a PR, or iterating on one. + */ +export type AutofixAction = AutofixNextStep['action']; const AUTOFIX_CREDIT_OPERATIONS: Record = { root_cause: 'autofix_root_cause', diff --git a/static/app/components/events/autofix/v3/codeChangesCard.tsx b/static/app/components/events/autofix/v3/codeChangesCard.tsx index 3ac999a8dbeb..b226189aedb0 100644 --- a/static/app/components/events/autofix/v3/codeChangesCard.tsx +++ b/static/app/components/events/autofix/v3/codeChangesCard.tsx @@ -23,10 +23,7 @@ import { } from 'sentry/components/events/autofix/useExplorerAutofix'; import {SeerMarkdown} from 'sentry/components/seer/markdown'; import {OUT_OF_CREDITS_STEP_MESSAGE} from 'sentry/components/seer/outOfCredits/messages'; -import { - AUTOFIX_STEP_CREDIT_OPERATION, - useOutOfCreditsRetry, -} from 'sentry/components/seer/outOfCredits/useOutOfCreditsRetry'; +import {useOutOfCreditsRetry} from 'sentry/components/seer/outOfCredits/useOutOfCreditsRetry'; import {t, tn} from 'sentry/locale'; import {defined} from 'sentry/utils/defined'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; @@ -94,10 +91,9 @@ export function CodeChangesCard({autofix, groupId, section}: CodeChangesCardProp const isOutOfCredits = isOutOfCreditsSection(section); const stoppedStep = isLatestStepPrIteration ? 'pr_iteration' : 'code_changes'; - const outOfCreditsRetry = useOutOfCreditsRetry( - AUTOFIX_STEP_CREDIT_OPERATION[stoppedStep], - {enabled: isOutOfCredits} - ); + const outOfCreditsRetry = useOutOfCreditsRetry(stoppedStep, { + enabled: isOutOfCredits, + }); // The blocks of the step that is running, or that stopped for credits. const currentStepBlocks = useMemo(() => { diff --git a/static/app/components/events/autofix/v3/pullRequestsCard.tsx b/static/app/components/events/autofix/v3/pullRequestsCard.tsx index 423cc49cb972..5644346ec2a6 100644 --- a/static/app/components/events/autofix/v3/pullRequestsCard.tsx +++ b/static/app/components/events/autofix/v3/pullRequestsCard.tsx @@ -49,7 +49,7 @@ export function PullRequestsCard({autofix, section}: PullRequestsCardProps) { const outOfCreditsPullRequests = artifact?.filter(isOutOfCreditsPRState) ?? []; // Their "Retry PR" fails the same way until the organization can afford it. - const outOfCreditsRetry = useOutOfCreditsRetry('autofix_open_pr', { + const outOfCreditsRetry = useOutOfCreditsRetry('create_pr', { enabled: defined(runId) && outOfCreditsPullRequests.length > 0, }); diff --git a/static/app/components/events/autofix/v3/rootCauseCard.tsx b/static/app/components/events/autofix/v3/rootCauseCard.tsx index c3049a31e0e3..0f5b2394b863 100644 --- a/static/app/components/events/autofix/v3/rootCauseCard.tsx +++ b/static/app/components/events/autofix/v3/rootCauseCard.tsx @@ -66,7 +66,7 @@ export function RootCauseCard({autofix, groupId, section}: RootCauseCardProps) { ); const isOutOfCredits = isOutOfCreditsSection(section); - const outOfCreditsRetry = useOutOfCreditsRetry('autofix_root_cause', { + const outOfCreditsRetry = useOutOfCreditsRetry('root_cause', { enabled: isOutOfCredits, }); diff --git a/static/app/components/events/autofix/v3/solutionCard.tsx b/static/app/components/events/autofix/v3/solutionCard.tsx index aa2da9168e61..af5c5e1458d5 100644 --- a/static/app/components/events/autofix/v3/solutionCard.tsx +++ b/static/app/components/events/autofix/v3/solutionCard.tsx @@ -47,7 +47,7 @@ export function SolutionCard({autofix, section}: SolutionCardProps) { ); const isOutOfCredits = isOutOfCreditsSection(section); - const outOfCreditsRetry = useOutOfCreditsRetry('autofix_solution', { + const outOfCreditsRetry = useOutOfCreditsRetry('solution', { enabled: isOutOfCredits, }); diff --git a/static/app/components/seer/markdown/embeds/components/autofix.tsx b/static/app/components/seer/markdown/embeds/components/autofix.tsx index eb3fea254f4c..566459bf305a 100644 --- a/static/app/components/seer/markdown/embeds/components/autofix.tsx +++ b/static/app/components/seer/markdown/embeds/components/autofix.tsx @@ -51,10 +51,7 @@ import { OUT_OF_CREDITS_STEP_MESSAGE, } from 'sentry/components/seer/outOfCredits/messages'; import {SeerOutOfCreditsCta} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; -import { - AUTOFIX_STEP_CREDIT_OPERATION, - useOutOfCreditsRetry, -} from 'sentry/components/seer/outOfCredits/useOutOfCreditsRetry'; +import {useOutOfCreditsRetry} from 'sentry/components/seer/outOfCredits/useOutOfCreditsRetry'; import {t, tn} from 'sentry/locale'; import type {Group} from 'sentry/types/group'; import {defined} from 'sentry/utils/defined'; @@ -317,9 +314,7 @@ function AutofixRefContent({id, shortId, step}: AutofixRefContentProps) { const outOfCreditsSection = section && isOutOfCreditsSection(section) ? section : undefined; const stepRetry = useOutOfCreditsRetry( - AUTOFIX_STEP_CREDIT_OPERATION[ - outOfCreditsSection ? getStoppedStep(outOfCreditsSection, step) : step - ], + outOfCreditsSection ? getStoppedStep(outOfCreditsSection, step) : step, {enabled: defined(outOfCreditsSection)} ); // Draft a pull request retries a create that failed for credits. The credit diff --git a/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.spec.tsx b/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.spec.tsx index 8c418c21ab79..7bcb73106760 100644 --- a/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.spec.tsx +++ b/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.spec.tsx @@ -1,6 +1,13 @@ +import {QueryClientProvider} from '@tanstack/react-query'; import {OrganizationFixture} from 'sentry-fixture/organization'; -import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; +import {makeTestQueryClient} from 'sentry-test/queryClient'; +import { + render, + renderHookWithProviders, + screen, + waitFor, +} from 'sentry-test/reactTestingLibrary'; import {useOutOfCreditsRetry} from './useOutOfCreditsRetry'; @@ -25,7 +32,7 @@ describe('useOutOfCreditsRetry', () => { mockCreditCheck(false); const {result} = renderHookWithProviders( - () => useOutOfCreditsRetry('autofix_code_changes', {enabled: true}), + () => useOutOfCreditsRetry('code_changes', {enabled: true}), {organization} ); @@ -42,7 +49,7 @@ describe('useOutOfCreditsRetry', () => { mockCreditCheck(true); const {result} = renderHookWithProviders( - () => useOutOfCreditsRetry('autofix_code_changes', {enabled: true}), + () => useOutOfCreditsRetry('code_changes', {enabled: true}), {organization} ); @@ -51,11 +58,50 @@ describe('useOutOfCreditsRetry', () => { ); }); + it('holds the retry back until its own check answers, even with an answer cached', async () => { + function RetryButton() { + const {disabled} = useOutOfCreditsRetry('code_changes', {enabled: true}); + return ; + } + // One cache across both mounts, as the app keeps one. + const queryClient = makeTestQueryClient(); + const tree = ( + + + + ); + + mockCreditCheck(true); + const {unmount} = render(tree, {organization}); + await waitFor(() => expect(screen.getByRole('button')).toBeEnabled()); + unmount(); + + // Credits run out before the retry is back on screen, and the recheck + // hasn't answered yet. The cached answer from before must not enable it. + let answerRecheck!: () => void; + MockApiClient.clearMockResponses(); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/seer/credit-check/`, + match: [MockApiClient.matchQuery({operation: 'autofix_code_changes'})], + body: {hasEnoughCredits: false}, + asyncDelay: new Promise(resolve => { + answerRecheck = resolve; + }), + }); + render(tree, {organization}); + + expect(screen.getByRole('button')).toBeDisabled(); + + answerRecheck(); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(screen.getByRole('button')).toBeDisabled(); + }); + it('does not check when the retry is not on screen', () => { const request = mockCreditCheck(false); const {result} = renderHookWithProviders( - () => useOutOfCreditsRetry('autofix_code_changes', {enabled: false}), + () => useOutOfCreditsRetry('code_changes', {enabled: false}), {organization} ); diff --git a/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.tsx b/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.tsx index 9d725d1d1e3f..20ffd19a694a 100644 --- a/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.tsx +++ b/static/app/components/seer/outOfCredits/useOutOfCreditsRetry.tsx @@ -1,24 +1,10 @@ -import type {AutofixExplorerStep} from 'sentry/components/events/autofix/useExplorerAutofix'; import { - useSeerCreditCheck, - type SeerCreditOperation, -} from 'sentry/utils/seer/useSeerCreditCheck'; + useAutofixCreditCheck, + type AutofixAction, +} from 'sentry/components/events/autofix/autofixCredits'; import {OUT_OF_CREDITS_RETRY_TOOLTIP} from './messages'; -/** - * The operation that running an autofix step again spends credits on. - */ -export const AUTOFIX_STEP_CREDIT_OPERATION: Record< - AutofixExplorerStep, - SeerCreditOperation -> = { - root_cause: 'autofix_root_cause', - solution: 'autofix_solution', - code_changes: 'autofix_code_changes', - pr_iteration: 'autofix_pr_iteration', -}; - interface UseOutOfCreditsRetryOptions { /** * The retry is on screen for something that stopped for credits. Nothing is @@ -30,20 +16,23 @@ interface UseOutOfCreditsRetryOptions { /** * Gates the retry of a step or PR that stopped because the organization ran * out of Seer credits. The retry stays on screen, disabled while the - * organization can't afford `operation`, and comes back once it can. + * organization can't afford `action`, and comes back once it can. * * Fails open like `useSeerCreditCheck`: without `seer-credit-checks` the retry * is enabled and the backend decides. */ export function useOutOfCreditsRetry( - operation: SeerCreditOperation, + action: AutofixAction, {enabled}: UseOutOfCreditsRetryOptions ) { - const {hasEnoughCredits, isPending} = useSeerCreditCheck(operation, {enabled}); + const {hasCheckedSinceMount, hasEnoughCredits} = useAutofixCreditCheck(action, { + enabled, + }); return { - // Held back while the check is in flight so it doesn't flash enabled. - disabled: isPending || !hasEnoughCredits, + // Held back until this mount's check answers, so it doesn't flash enabled + // on an earlier mount's cached answer. + disabled: !hasCheckedSinceMount || !hasEnoughCredits, tooltip: hasEnoughCredits ? undefined : OUT_OF_CREDITS_RETRY_TOOLTIP, }; } diff --git a/static/app/views/issueList/pages/inbox/issuePreview/issuePreviewSeerActions.tsx b/static/app/views/issueList/pages/inbox/issuePreview/issuePreviewSeerActions.tsx index a8be5eab6acf..2b8022c67ed5 100644 --- a/static/app/views/issueList/pages/inbox/issuePreview/issuePreviewSeerActions.tsx +++ b/static/app/views/issueList/pages/inbox/issuePreview/issuePreviewSeerActions.tsx @@ -261,7 +261,7 @@ function RetryPullRequestButton({ variant, }: RetryPullRequestButtonProps) { const isOutOfCredits = isOutOfCreditsPRState(failedPullRequest); - const outOfCreditsRetry = useOutOfCreditsRetry('autofix_open_pr', { + const outOfCreditsRetry = useOutOfCreditsRetry('create_pr', { enabled: isOutOfCredits, }); diff --git a/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx b/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx index 54bc441a4e7e..88f91414e608 100644 --- a/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx +++ b/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx @@ -42,6 +42,7 @@ interface Wallet { /** * Answers every query the chat window makes from fixtures, so the story never reaches the API. + * Without a wallet, the wallet's requests fail as they do for an org without token-billed Seer. * Per-query options outrank client defaults, which is why this replaces `queryFn` after * defaulting rather than setting a default. Sending a message is a mutation and is not stubbed. */ @@ -72,7 +73,10 @@ class FixtureQueryClient extends QueryClient { dailySpendCapCents: 5000, }; } - if (this.wallet && url.includes('/seer-wallet/')) { + if (url.includes('/seer-wallet/')) { + if (!this.wallet) { + throw new Error('This story has no wallet'); + } const {remaining, total} = this.wallet; return { balance: remaining, @@ -88,7 +92,9 @@ class FixtureQueryClient extends QueryClient { override defaultQueryOptions: QueryClient['defaultQueryOptions'] = options => { const defaulted = super.defaultQueryOptions(options); const url = String(defaulted.queryKey[0]); - const queryFn = () => Promise.resolve({json: this.respond(url), headers: {}}); + // `then` turns a fixture that throws into a failed query. + const queryFn = () => + Promise.resolve().then(() => ({json: this.respond(url), headers: {}})); // Each fixture matches the response type of the query that asked for it. return {...defaulted, queryFn: queryFn as never}; }; @@ -123,8 +129,11 @@ function ChatWindow({session, hasEnoughCredits, initialQuery, wallet}: ChatWindo scope !== 'org:billing'), openMembership: true, hideAiFeatures: false, features: [ @@ -572,9 +581,11 @@ export default Storybook.story('ChatWindow', story => { failure_reason: out_of_credits. The reasoning and tool calls Seer made before it stopped stay in the thinking block, and a muted line saying Seer stopped for credits replaces Seer's plain-text error message. There is no CTA in - the transcript: the out-of-credits alert above the composer covers it. + the transcript: the chat rechecks credits when a run stops for them, and the + out-of-credits alert above the composer covers it. Open source renders no CTA; + getsentry fills it in.

- + )); diff --git a/static/app/views/seerExplorer/components/seerExplorerContent.tsx b/static/app/views/seerExplorer/components/seerExplorerContent.tsx index f45e7482ec03..622beefd3657 100644 --- a/static/app/views/seerExplorer/components/seerExplorerContent.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerContent.tsx @@ -941,7 +941,7 @@ export function SeerExplorerContent({ isQuestionPending || showReauth } - outOfCredits={sessionData?.failure_reason === 'out_of_credits'} + outOfCredits={isRunOutOfCredits} pendingInput={pendingInput} readOnly={readOnly} showThinking={showThinking} From 3b945d04d7d9a12c3a8c9f21990edb7e10cb081e Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Fri, 13 Nov 2026 13:03:36 -0500 Subject: [PATCH 8/8] feat(seer): Say when Autofix refuses a step or PR for credits When the organization has run out of Seer credits, the Autofix endpoint refuses a step or PR with a 402 and `code: seer_out_of_credits`. `startStep` and `createPR` treated that like any other failure. They toasted the backend's detail, and a refused kickoff with no run yet painted a failed run that said "This one is on us". Both now recheck every Seer credit check, so the credit gates bring up the out-of-credits CTA and disable their buttons, and toast out-of-credits copy for the step, or for the repo's PR. A refused kickoff no longer paints a failed run, since the start card offers the CTA instead. Co-Authored-By: Claude Opus 5.5 --- .../autofix/useExplorerAutofix.spec.tsx | 111 ++++++++++++++++++ .../events/autofix/useExplorerAutofix.tsx | 54 ++++++++- .../components/seer/outOfCredits/messages.tsx | 31 ++++- 3 files changed, 193 insertions(+), 3 deletions(-) diff --git a/static/app/components/events/autofix/useExplorerAutofix.spec.tsx b/static/app/components/events/autofix/useExplorerAutofix.spec.tsx index 38692dc54103..55945ea23914 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.spec.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.spec.tsx @@ -5,6 +5,7 @@ import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLib import {toast} from '@sentry/scraps/toast'; +import {useSeerCreditCheck} from 'sentry/utils/seer/useSeerCreditCheck'; import type {Artifact, Block, ExplorerFilePatch} from 'sentry/views/seerExplorer/types'; import {DiffFileType, DiffLineType} from './types'; @@ -1283,6 +1284,71 @@ describe('useExplorerAutofix - createPR', () => { ); }); + it('says a push was refused for credits and rechecks credits', async () => { + const errorSpy = jest.spyOn(toast, 'error'); + const organization = OrganizationFixture({ + features: ['seer-credit-checks', 'token-billed-seer-enabled'], + }); + const creditCheck = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/seer/credit-check/`, + body: {hasEnoughCredits: true}, + }); + MockApiClient.addMockResponse({ + url: AUTOFIX_URL, + method: 'GET', + body: { + autofix: { + run_id: 42, + blocks: [], + status: 'completed', + updated_at: '2026-01-01T00:00:00Z', + repo_pr_states: { + 'org/repo': { + repo_name: 'org/repo', + branch_name: 'seer/fix', + commit_sha: 'abc1234', + pr_creation_status: 'error', + pr_creation_error: null, + pr_id: 7, + pr_number: 7, + pr_url: 'https://github.com/org/repo/pull/7', + title: 'Fix', + }, + }, + }, + }, + }); + MockApiClient.addMockResponse({ + url: AUTOFIX_URL, + method: 'POST', + statusCode: 402, + body: { + detail: + 'Your organization does not have enough credits to continue your Autofix run.', + code: 'seer_out_of_credits', + }, + }); + + const {result} = renderHookWithProviders( + () => ({ + autofix: useExplorerAutofix(MOCK_GROUP), + credits: useSeerCreditCheck('autofix_open_pr'), + }), + {organization} + ); + await waitFor(() => expect(result.current.autofix.runState?.run_id).toBe(42)); + await waitFor(() => expect(creditCheck).toHaveBeenCalledTimes(1)); + + await expect( + act(() => result.current.autofix.createPR(42, 'org/repo')) + ).rejects.toThrow(); + + expect(errorSpy).toHaveBeenCalledWith( + "Seer couldn't push changes to org/repo#7 because your organization is out of credits." + ); + await waitFor(() => expect(creditCheck).toHaveBeenCalledTimes(2)); + }); + it('shows an error toast and throws on API error', async () => { MockApiClient.addMockResponse({ url: AUTOFIX_URL, @@ -1748,6 +1814,51 @@ describe('useExplorerAutofix - startStep errors', () => { ); }); + it('says a kickoff was refused for credits, rechecks credits, and paints no failed run', async () => { + const errorSpy = jest.spyOn(toast, 'error'); + const organization = OrganizationFixture({ + features: ['seer-credit-checks', 'token-billed-seer-enabled'], + }); + const creditCheck = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/seer/credit-check/`, + body: {hasEnoughCredits: true}, + }); + MockApiClient.addMockResponse({ + url: AUTOFIX_URL, + method: 'GET', + body: {autofix: null}, + }); + MockApiClient.addMockResponse({ + url: AUTOFIX_URL, + method: 'POST', + statusCode: 402, + body: { + detail: 'Your organization does not have enough credits to start an Autofix run.', + code: 'seer_out_of_credits', + }, + }); + + const {result} = renderHookWithProviders( + () => ({ + autofix: useExplorerAutofix(MOCK_GROUP), + credits: useSeerCreditCheck('autofix_root_cause'), + }), + {organization} + ); + await waitFor(() => expect(result.current.autofix.isLoading).toBe(false)); + await waitFor(() => expect(creditCheck).toHaveBeenCalledTimes(1)); + + await expect( + act(() => result.current.autofix.startStep('root_cause')) + ).rejects.toThrow(); + + expect(errorSpy).toHaveBeenCalledWith( + "Seer couldn't start on the root cause because your organization is out of credits." + ); + expect(result.current.autofix.runState).toBeNull(); + await waitFor(() => expect(creditCheck).toHaveBeenCalledTimes(2)); + }); + it('falls back to the error state when a kickoff fails with no existing run', async () => { MockApiClient.addMockResponse({ url: AUTOFIX_URL, diff --git a/static/app/components/events/autofix/useExplorerAutofix.tsx b/static/app/components/events/autofix/useExplorerAutofix.tsx index e29a759a8c54..b6d1f26932c3 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.tsx @@ -4,6 +4,11 @@ import {useQuery, useQueryClient} from '@tanstack/react-query'; import {useModal} from '@sentry/scraps/modal'; import {toast} from '@sentry/scraps/toast'; +import { + getOutOfCreditsPRMessage, + OUT_OF_CREDITS_OPEN_PRS_MESSAGE, + OUT_OF_CREDITS_START_STEP_MESSAGE, +} from 'sentry/components/seer/outOfCredits/messages'; import {useServiceWorker} from 'sentry/serviceWorker/client/serviceWorkerContext'; import type {Group} from 'sentry/types/group'; import type {User} from 'sentry/types/user'; @@ -12,6 +17,7 @@ import {trackAnalytics} from 'sentry/utils/analytics'; import {apiOptions} from 'sentry/utils/api/apiOptions'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {defined} from 'sentry/utils/defined'; +import {seerCreditChecksQueryKey} from 'sentry/utils/seer/useSeerCreditCheck'; import {useApi} from 'sentry/utils/useApi'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useUser} from 'sentry/utils/useUser'; @@ -328,6 +334,19 @@ function isAutofixSetupRequiredError( ); } +/** + * A step or PR the backend refused because the organization is out of Seer + * credits. The triggers' credit checks fail open and can be out of date, so + * the backend has the final say. + */ +function isOutOfCreditsError(e: unknown): boolean { + const error = e as + | {responseJSON?: {code?: unknown}; status?: number} + | null + | undefined; + return error?.status === 402 && error.responseJSON?.code === 'seer_out_of_credits'; +} + const makeErrorExplorerAutofixData = (errorMessage: string): ExplorerAutofixResponse => ({ autofix: { run_id: 0, @@ -1014,6 +1033,18 @@ export function useExplorerAutofix( return {runId: getAutofixRunId(e.responseJSON)!, isSingleRun: false}; } + // Recheck every credit check, so the credit gates bring up the + // out-of-credits CTA and hold their buttons back, and say why the step + // didn't start. A refused kickoff paints no failed run: the start card + // offers the CTA instead. + if (isOutOfCreditsError(e)) { + queryClient.invalidateQueries({ + queryKey: seerCreditChecksQueryKey(organization), + }); + toast.error(OUT_OF_CREDITS_START_STEP_MESSAGE[step]); + throw e; + } + // The kickoff was refused because Seer setup is incomplete. There is no // failed run to paint, and unlike the in-flight race there is nothing to // recover by refetching — surface the backend's setup copy (it names the @@ -1079,11 +1110,30 @@ export function useExplorerAutofix( queryKey: explorerAutofixApiOptions(orgSlug, groupId).queryKey, }); } catch (e: any) { - toast.error(getApiErrorMessage(e, 'Failed to create PR')); + if (!isOutOfCreditsError(e)) { + toast.error(getApiErrorMessage(e, 'Failed to create PR')); + throw e; + } + + // Recheck every credit check, so the credit gates bring up the + // out-of-credits CTA and hold their buttons back. + queryClient.invalidateQueries({queryKey: seerCreditChecksQueryKey(organization)}); + // A retry for a repo whose PR exists pushes to it rather than opening one. + const repoState = repoName + ? queryClient.getQueryData(explorerAutofixApiOptions(orgSlug, groupId).queryKey) + ?.json?.autofix?.repo_pr_states?.[repoName] + : undefined; + toast.error( + repoName + ? getOutOfCreditsPRMessage( + repoState ?? {repo_name: repoName, pr_number: null} + ) + : OUT_OF_CREDITS_OPEN_PRS_MESSAGE + ); throw e; } }, - [api, orgSlug, groupId, queryClient] + [api, orgSlug, groupId, organization, queryClient] ); /** diff --git a/static/app/components/seer/outOfCredits/messages.tsx b/static/app/components/seer/outOfCredits/messages.tsx index aed26bdb1b52..9b948b1f0a19 100644 --- a/static/app/components/seer/outOfCredits/messages.tsx +++ b/static/app/components/seer/outOfCredits/messages.tsx @@ -22,6 +22,33 @@ export const OUT_OF_CREDITS_STEP_MESSAGE: Record = ), }; +/** + * Toast for an autofix step the backend refused to start because the + * organization is out of credits. + */ +export const OUT_OF_CREDITS_START_STEP_MESSAGE: Record = { + root_cause: t( + "Seer couldn't start on the root cause because your organization is out of credits." + ), + solution: t( + "Seer couldn't start on the plan because your organization is out of credits." + ), + code_changes: t( + "Seer couldn't start on the code changes because your organization is out of credits." + ), + pr_iteration: t( + "Seer couldn't update the pull request because your organization is out of credits." + ), +}; + +/** + * Toast for pull requests the backend refused to open because the + * organization is out of credits, when the request named no repo. + */ +export const OUT_OF_CREDITS_OPEN_PRS_MESSAGE = t( + "Seer couldn't open a pull request because your organization is out of credits." +); + export const OUT_OF_CREDITS_CHAT_MESSAGE = t( 'Seer stopped because your organization is out of credits.' ); @@ -37,7 +64,9 @@ export const OUT_OF_CREDITS_RETRY_TOOLTIP = t( * Stands in for the backend's `pr_creation_error` on a PR that failed for * credits. A PR number means the PR already exists, so the push failed. */ -export function getOutOfCreditsPRMessage(state: RepoPRState): string { +export function getOutOfCreditsPRMessage( + state: Pick +): string { return state.pr_number ? t( "Seer couldn't push changes to %s#%s because your organization is out of credits.",