Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
306f90f
feat(seer): Gate agent chat on Seer credits
billyvg Nov 2, 2026
6cec027
feat(seer): Show Seer credit usage next to the chat send button
billyvg Nov 5, 2026
4a23d84
test(seer): Turn on token-billed Seer where credit checks are tested
billyvg Nov 5, 2026
be233f8
ref(seer): Check credit flags in the usage meter itself
billyvg Nov 5, 2026
2dd7f96
feat(seer): Match the chat composer and credit meter to the Billing G…
billyvg Nov 6, 2026
3d1f564
fix(seer): Send a forwarded question before the user's own messages
billyvg Nov 6, 2026
15ce1ca
fix(seer): Reload the credit usage meter on window focus
billyvg Nov 6, 2026
9fa9351
fix(seer): Show the credits notice when a chat runs out mid-conversation
srest2021 Nov 9, 2026
9e67cee
test(seer): Match out-of-credits test names to the rest of the file
srest2021 Nov 9, 2026
ba0ae45
test(seer): Trim the chat out-of-credits tests
srest2021 Nov 9, 2026
f346c23
feat(seer): Gate agent chat on Seer credits
billyvg Nov 2, 2026
3b5018e
feat(seer): Show Seer credit usage next to the chat send button
billyvg Nov 5, 2026
a1163dd
test(seer): Turn on token-billed Seer where credit checks are tested
billyvg Nov 5, 2026
d970cd8
ref(seer): Check credit flags in the usage meter itself
billyvg Nov 5, 2026
b6bb1fc
feat(seer): Match the chat composer and credit meter to the Billing G…
billyvg Nov 6, 2026
7535a3a
fix(seer): Send a forwarded question before the user's own messages
billyvg Nov 6, 2026
1363e81
fix(seer): Reload the credit usage meter on window focus
billyvg Nov 6, 2026
ddaf25b
Apply suggestion from @srest2021
billyvg Nov 9, 2026
8c93e06
ref(seer): Name the composer's toolbar slot after where it renders
billyvg Nov 9, 2026
1eb7886
test(seer): Look up the meter's auto-reload link by its new copy
billyvg Nov 9, 2026
76d19a5
fix(seer): Wait for the chat's own credit check before acting on a fo…
billyvg Nov 9, 2026
572db2a
fix(seer): Let the credit recheck explain a send refused for credits
srest2021 Nov 9, 2026
4d42d2f
Merge remote-tracking branch 'origin/billyvong/cw-2332-sentry-ui-gate…
srest2021 Nov 9, 2026
90a2f14
fix(seer): Recheck credits for every send refused for credits
srest2021 Nov 9, 2026
8445d18
Merge remote-tracking branch 'origin/master' into srest2021/CW-2332-c…
srest2021 Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions static/app/utils/seer/useSeerCreditCheck.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -19,6 +20,20 @@ interface SeerCreditCheckResponse {
hasEnoughCredits: boolean;
}

export function seerCreditCheckApiOptions(
organization: Organization,
operation: SeerCreditOperation
) {
return apiOptions.as<SeerCreditCheckResponse>()(
'/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.
Expand Down Expand Up @@ -51,14 +66,8 @@ export function useSeerCreditCheck(
organization.features.includes('token-billed-seer-enabled');

const query = useQuery({
...apiOptions.as<SeerCreditCheckResponse>()(
'/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
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -2140,11 +2142,8 @@ describe('SeerExplorerContent', () => {
});
}

function renderContent({
org = creditsOrganization,
initialQuery,
}: {initialQuery?: string; org?: typeof creditsOrganization} = {}) {
return render(
function contentTree(initialQuery?: string) {
return (
<PictureInPictureProvider>
<SeerExplorerSessionsProvider>
<SeerExplorerContent
Expand All @@ -2153,11 +2152,17 @@ describe('SeerExplorerContent', () => {
initialQuery={initialQuery}
/>
</SeerExplorerSessionsProvider>
</PictureInPictureProvider>,
{organization: org}
</PictureInPictureProvider>
);
}

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();
Expand Down Expand Up @@ -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 (
<QueryClientProvider client={queryClient}>{contentTree()}</QueryClientProvider>
);
}

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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -155,6 +158,7 @@ export function SeerExplorerContent({
sidebarPosition?: SeerExplorerSidebarPosition;
}) {
const organization = useOrganization({allowNull: true});
const queryClient = useQueryClient();
const {projects} = useProjects();
const user = useUser();
const {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -496,13 +512,16 @@ export function SeerExplorerContent({

const showReauth = isReauthPending && isReauthEnabled(organization);

const requestErrorAlert = requestError ? (
<Container padding="0 xl">
<Alert variant="danger">
<Text>{t('There was an error sending your message, wait and try again.')}</Text>
</Alert>
</Container>
) : 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 ? (
<Container padding="0 xl">
<Alert variant="danger">
<Text>{t('There was an error sending your message, wait and try again.')}</Text>
</Alert>
</Container>
) : null;

// - Topbar, menu, and slash command handlers -------------------------------
const copySessionEnabled = runId !== null && !!organization?.slug;
Expand Down
54 changes: 48 additions & 6 deletions static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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');
Expand All @@ -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();
});
Expand Down Expand Up @@ -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<SeerExplorerResponse>(queryClient, queryKey)).toEqual(
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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(() => {
Expand Down
Loading
Loading