diff --git a/static/app/utils/seer/useSeerCreditCheck.tsx b/static/app/utils/seer/useSeerCreditCheck.tsx index d05bbf38e565..9192b600c17b 100644 --- a/static/app/utils/seer/useSeerCreditCheck.tsx +++ b/static/app/utils/seer/useSeerCreditCheck.tsx @@ -1,5 +1,6 @@ -import {skipToken, useQuery} from '@tanstack/react-query'; +import {useQuery} from '@tanstack/react-query'; +import type {Organization} from 'sentry/types/organization'; import {apiOptions} from 'sentry/utils/api/apiOptions'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -19,6 +20,20 @@ interface SeerCreditCheckResponse { hasEnoughCredits: boolean; } +export function seerCreditCheckApiOptions( + organization: Organization, + operation: SeerCreditOperation +) { + return apiOptions.as()( + '/organizations/$organizationIdOrSlug/seer/credit-check/', + { + path: {organizationIdOrSlug: organization.slug}, + query: {operation}, + staleTime: 0, + } + ); +} + interface UseSeerCreditCheckOptions { /** * Skip the check, for example while the trigger it gates is not on screen. @@ -51,14 +66,8 @@ export function useSeerCreditCheck( organization.features.includes('token-billed-seer-enabled'); const query = useQuery({ - ...apiOptions.as()( - '/organizations/$organizationIdOrSlug/seer/credit-check/', - { - path: isChecking ? {organizationIdOrSlug: organization.slug} : skipToken, - query: {operation}, - staleTime: 0, - } - ), + ...seerCreditCheckApiOptions(organization, operation), + enabled: isChecking, // Credits move outside this page: a run elsewhere spends them and a billing // member can top up from another tab. Recheck on every mount and window // focus (which the app's query client turns off by default) so a gate diff --git a/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx b/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx index cd32cd2ce056..9a676bab676f 100644 --- a/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx @@ -1,10 +1,12 @@ import {useState} from 'react'; +import {QueryClientProvider, type QueryClient} from '@tanstack/react-query'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations'; import {PageFiltersFixture} from 'sentry-fixture/pageFilters'; import {ProjectFixture} from 'sentry-fixture/project'; import {UserFixture} from 'sentry-fixture/user'; +import {makeTestQueryClient} from 'sentry-test/queryClient'; import { act, render, @@ -2140,11 +2142,8 @@ describe('SeerExplorerContent', () => { }); } - function renderContent({ - org = creditsOrganization, - initialQuery, - }: {initialQuery?: string; org?: typeof creditsOrganization} = {}) { - return render( + function contentTree(initialQuery?: string) { + return ( { initialQuery={initialQuery} /> - , - {organization: org} + ); } + function renderContent({ + org = creditsOrganization, + initialQuery, + }: {initialQuery?: string; org?: typeof creditsOrganization} = {}) { + return render(contentTree(initialQuery), {organization: org}); + } + it('shows the CTA above the composer and keeps the draft instead of sending', async () => { mockCreditCheck(false); const sendMessage = jest.fn(); @@ -2381,6 +2386,95 @@ describe('SeerExplorerContent', () => { ).not.toBeInTheDocument(); }); + describe('after running out', () => { + // The test `rerender` mounts a new QueryClient, which would drop the credit + // check these tests watch, so they keep their own. + let queryClient: QueryClient; + beforeEach(() => { + queryClient = makeTestQueryClient(); + }); + function tree() { + return ( + {contentTree()} + ); + } + + it('rechecks credits when Seer stops a reply for credits', async () => { + const useSeerExplorerSpy = jest + .spyOn(useSeerExplorerModule, 'useSeerExplorer') + .mockReturnValue({...defaultHookReturn, runId: 123}); + const creditCheck = mockCreditCheck(true); + const {rerender} = render(tree(), {organization: creditsOrganization}); + await waitFor(() => expect(creditCheck).toHaveBeenCalled()); + + mockCreditCheck(false); + useSeerExplorerSpy.mockReturnValue({ + ...defaultHookReturn, + runId: 123, + sessionData: { + blocks: [], + status: 'error', + updated_at: '2024-01-01T00:01:00Z', + failure_reason: 'out_of_credits', + }, + }); + rerender(tree()); + + expect( + await screen.findByTestId('seer-explorer-out-of-credits') + ).toBeInTheDocument(); + }); + + it('rechecks credits when a send is refused for credits', async () => { + const useSeerExplorerSpy = jest + .spyOn(useSeerExplorerModule, 'useSeerExplorer') + .mockReturnValue(defaultHookReturn); + const creditCheck = mockCreditCheck(true); + const {rerender} = render(tree(), {organization: creditsOrganization}); + await waitFor(() => expect(creditCheck).toHaveBeenCalled()); + + mockCreditCheck(false); + useSeerExplorerSpy.mockReturnValue({ + ...defaultHookReturn, + requestError: {query: 'Why is this slow?', isOutOfCredits: true}, + }); + rerender(tree()); + + expect( + await screen.findByTestId('seer-explorer-out-of-credits') + ).toBeInTheDocument(); + expect( + screen.queryByText( + 'There was an error sending your message, wait and try again.' + ) + ).not.toBeInTheDocument(); + }); + + it('rechecks credits for each send refused for credits', async () => { + const useSeerExplorerSpy = jest + .spyOn(useSeerExplorerModule, 'useSeerExplorer') + .mockReturnValue(defaultHookReturn); + // The check keeps saying there are credits, so sending stays possible. + const creditCheck = mockCreditCheck(true); + const {rerender} = render(tree(), {organization: creditsOrganization}); + await waitFor(() => expect(creditCheck).toHaveBeenCalledTimes(1)); + + useSeerExplorerSpy.mockReturnValue({ + ...defaultHookReturn, + requestError: {query: 'Why is this slow?', isOutOfCredits: true}, + }); + rerender(tree()); + await waitFor(() => expect(creditCheck).toHaveBeenCalledTimes(2)); + + useSeerExplorerSpy.mockReturnValue({ + ...defaultHookReturn, + requestError: {query: 'Why is this slow?', isOutOfCredits: true}, + }); + rerender(tree()); + await waitFor(() => expect(creditCheck).toHaveBeenCalledTimes(3)); + }); + }); + it('does not check credits without seer-credit-checks', async () => { const creditCheck = mockCreditCheck(false); const sendMessage = jest.fn(); diff --git a/static/app/views/seerExplorer/components/seerExplorerContent.tsx b/static/app/views/seerExplorer/components/seerExplorerContent.tsx index 38749fb1719f..f05e14165f99 100644 --- a/static/app/views/seerExplorer/components/seerExplorerContent.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerContent.tsx @@ -11,7 +11,7 @@ import { import styled from '@emotion/styled'; import {IconClose} from '@sentry/icons/close'; import {IconRefresh} from '@sentry/icons/refresh'; -import {skipToken, useQuery} from '@tanstack/react-query'; +import {skipToken, useQuery, useQueryClient} from '@tanstack/react-query'; import {Alert} from '@sentry/scraps/alert'; import {Button} from '@sentry/scraps/button'; @@ -37,7 +37,10 @@ import { canManageIntegrations, integrationRequiresUpgrade, } from 'sentry/utils/integrationUtil'; -import {useSeerCreditCheck} from 'sentry/utils/seer/useSeerCreditCheck'; +import { + seerCreditCheckApiOptions, + useSeerCreditCheck, +} from 'sentry/utils/seer/useSeerCreditCheck'; import {useDeferredSessionStorage} from 'sentry/utils/useDeferredSessionStorage'; import {useDimensions} from 'sentry/utils/useDimensions'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; @@ -155,6 +158,7 @@ export function SeerExplorerContent({ sidebarPosition?: SeerExplorerSidebarPosition; }) { const organization = useOrganization({allowNull: true}); + const queryClient = useQueryClient(); const {projects} = useProjects(); const user = useUser(); const { @@ -368,6 +372,18 @@ export function SeerExplorerContent({ // be out of date: a billing member may have topped up since. Only this // panel's own check counts. const isCheckingCredits = !hasCheckedSinceMount; + // Seer stopped a reply, or the backend refused a send, for credits. Recheck so the + // notice shows and sending is disabled. Every refused send rechecks, since an + // earlier recheck may have failed open or the balance may have moved since. + const refusedForCredits = requestError?.isOutOfCredits ? requestError : null; + const isRunOutOfCredits = sessionData?.failure_reason === 'out_of_credits'; + useEffect(() => { + if (organization && (refusedForCredits || isRunOutOfCredits)) { + queryClient.invalidateQueries({ + queryKey: seerCreditCheckApiOptions(organization, 'agent_chat_message').queryKey, + }); + } + }, [refusedForCredits, isRunOutOfCredits, organization, queryClient]); // A question forwarded from elsewhere waits for that check (see the // auto-submit below) and goes first. Until it does, the user's own sends wait // too, or one would land ahead of it and the forwarded question would be @@ -496,13 +512,16 @@ export function SeerExplorerContent({ const showReauth = isReauthPending && isReauthEnabled(organization); - const requestErrorAlert = requestError ? ( - - - {t('There was an error sending your message, wait and try again.')} - - - ) : null; + // A send refused for credits shows no error of its own. The recheck brings up the + // credits notice above the composer instead. + const requestErrorAlert = + requestError && !requestError.isOutOfCredits ? ( + + + {t('There was an error sending your message, wait and try again.')} + + + ) : null; // - Topbar, menu, and slash command handlers ------------------------------- const copySessionEnabled = runId !== null && !!organization?.slug; diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx index 95aaa5d5faa0..8f1d050d2269 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx @@ -337,6 +337,30 @@ describe('useSeerExplorer', () => { }); }); + it('flags a send refused for credits', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/seer/explorer-chat/`, + method: 'POST', + statusCode: 402, + body: {detail: 'Not enough credits'}, + }); + + const {result} = renderHookWithProviders(() => useSeerExplorer(), { + organization, + additionalWrapper: SeerExplorerChatStateProvider, + }); + + act(() => { + result.current.sendMessage('Test query'); + }); + await waitFor(() => { + expect(result.current.requestError).toEqual({ + query: 'Test query', + isOutOfCredits: true, + }); + }); + }); + it('keeps the existing chat and exposes the failed query when sending fails', async () => { const runId = 'run-with-history'; const existingBlocks = [ @@ -381,7 +405,10 @@ describe('useSeerExplorer', () => { }); await waitFor(() => { - expect(result.current.requestError).toEqual({query: 'Second question'}); + expect(result.current.requestError).toEqual({ + query: 'Second question', + isOutOfCredits: false, + }); }); expect(postMock).toHaveBeenCalled(); expect(result.current.sessionData?.status).toBe('completed'); @@ -399,7 +426,10 @@ describe('useSeerExplorer', () => { act(() => { result.current.sendMessage('Second question'); }); - expect(result.current.requestError).toEqual({query: 'Second question'}); + expect(result.current.requestError).toEqual({ + query: 'Second question', + isOutOfCredits: false, + }); await waitFor(() => { expect(result.current.requestError).toBeNull(); }); @@ -608,7 +638,10 @@ describe('useSeerExplorer', () => { result.current.sendMessage('Question'); }); await waitFor(() => { - expect(result.current.requestError).toEqual({query: 'Question'}); + expect(result.current.requestError).toEqual({ + query: 'Question', + isOutOfCredits: false, + }); }); expect(getApiQueryData(queryClient, queryKey)).toEqual( @@ -662,7 +695,10 @@ describe('useSeerExplorer', () => { result.current.sendMessage('Second question'); }); await waitFor(() => { - expect(result.current.requestError).toEqual({query: 'Second question'}); + expect(result.current.requestError).toEqual({ + query: 'Second question', + isOutOfCredits: false, + }); }); // Let the first request succeed and its callbacks run to completion. @@ -673,7 +709,10 @@ describe('useSeerExplorer', () => { } }); - expect(result.current.requestError).toEqual({query: 'Second question'}); + expect(result.current.requestError).toEqual({ + query: 'Second question', + isOutOfCredits: false, + }); }); // TanStack only calls per-call `mutate` callbacks for an observer's latest mutation, so @@ -783,7 +822,10 @@ describe('useSeerExplorer', () => { result.current.sendMessage('Will fail'); }); await waitFor(() => { - expect(result.current.requestError).toEqual({query: 'Will fail'}); + expect(result.current.requestError).toEqual({ + query: 'Will fail', + isOutOfCredits: false, + }); }); act(() => { diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx index 7ccf17488ed4..c891c0e787aa 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx @@ -155,9 +155,12 @@ export const useSeerExplorer = () => { // Set when the last chat message or user-input response failed, so the UI can show an // alert until a later request succeeds. `query` is the failed chat message, if any, // so the draft can be restored, and `chatPrompt` the question it answered. + // `isOutOfCredits` marks a message refused for Seer credits, which gets the credits + // notice instead of that alert. const [requestError, setRequestError] = useState<{ runId: SeerExplorerRunId | null; chatPrompt?: ChatPrompt; + isOutOfCredits?: boolean; query?: string; } | null>(null); const previousPRStatesRef = useRef>({}); @@ -310,7 +313,7 @@ export const useSeerExplorer = () => { }); } }, - onError: (_e, params, context) => { + onError: (error, params, context) => { // A later send (possibly in another conversation) may own the optimistic blocks now. setLastSentMessage(prev => (prev?.requestId === params.requestId ? null : prev)); if (!isLatestRequest(params)) { @@ -324,6 +327,7 @@ export const useSeerExplorer = () => { runId: params.runId, query: params.query, chatPrompt: params.pendingChatPrompt ?? undefined, + isOutOfCredits: error.status === 402, }); } }, @@ -419,10 +423,18 @@ export const useSeerExplorer = () => { setRequestError(null); } - const currentRequestError = useMemo<{chatPrompt?: ChatPrompt; query?: string} | null>( + const currentRequestError = useMemo<{ + chatPrompt?: ChatPrompt; + isOutOfCredits?: boolean; + query?: string; + } | null>( () => requestError?.runId === runId - ? {query: requestError.query, chatPrompt: requestError.chatPrompt} + ? { + query: requestError.query, + chatPrompt: requestError.chatPrompt, + isOutOfCredits: requestError.isOutOfCredits, + } : null, [requestError, runId] ); diff --git a/static/app/views/seerExplorer/types.tsx b/static/app/views/seerExplorer/types.tsx index 384ae93e20b7..9732ab0db687 100644 --- a/static/app/views/seerExplorer/types.tsx +++ b/static/app/views/seerExplorer/types.tsx @@ -263,7 +263,7 @@ export type SeerExplorerResponse = { status: 'processing' | 'completed' | 'error' | 'awaiting_user_input'; updated_at: string; blocks?: Block[]; - failure_reason?: 'timeout' | 'stalled' | null; + failure_reason?: 'timeout' | 'stalled' | 'out_of_credits' | null; owner_user_id?: number | null; pending_user_input?: PendingUserInput | null; repo_pr_states?: Record;