diff --git a/static/app/utils/analytics/seerAnalyticsEvents.tsx b/static/app/utils/analytics/seerAnalyticsEvents.tsx index 7563d68da037..cec533d69d2c 100644 --- a/static/app/utils/analytics/seerAnalyticsEvents.tsx +++ b/static/app/utils/analytics/seerAnalyticsEvents.tsx @@ -1,4 +1,5 @@ import type {Organization} from 'sentry/types/organization'; +import type {SeerExplorerRunId} from 'sentry/views/seerExplorer/types'; export type SeerAnalyticsEventsParameters = { 'ai_query.applied': { @@ -142,7 +143,7 @@ export type SeerAnalyticsEventsParameters = { conversations_url: string | undefined; explorer_url: string | undefined; langfuse_url: string | undefined; - run_id: number | undefined; + run_id: SeerExplorerRunId | undefined; type: 'positive' | 'negative'; }; 'seer.explorer.global_panel.opened': { @@ -164,7 +165,7 @@ export type SeerAnalyticsEventsParameters = { }; 'seer.explorer.session_link_copied': Record; 'seer.explorer.timed_out': { - run_id: number | null; + run_id: SeerExplorerRunId | null; }; }; diff --git a/static/app/views/dashboards/createFromSeerUtils.spec.tsx b/static/app/views/dashboards/createFromSeerUtils.spec.tsx index 3505581ac8cd..45ef1c079db1 100644 --- a/static/app/views/dashboards/createFromSeerUtils.spec.tsx +++ b/static/app/views/dashboards/createFromSeerUtils.spec.tsx @@ -31,7 +31,6 @@ function makeSession( overrides: Partial> = {} ): NonNullable { return { - run_id: 1, status: 'completed', updated_at: new Date().toISOString(), blocks: [], diff --git a/static/app/views/seerExplorer/components/chat/assistant.tsx b/static/app/views/seerExplorer/components/chat/assistant.tsx index abfd99011636..4264b2d21539 100644 --- a/static/app/views/seerExplorer/components/chat/assistant.tsx +++ b/static/app/views/seerExplorer/components/chat/assistant.tsx @@ -11,7 +11,7 @@ import {trackAnalytics} from 'sentry/utils/analytics'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useSessionStorage} from 'sentry/utils/useSessionStorage'; import {getConversationsUrlForExternalUse} from 'sentry/views/explore/conversations/utils/urlParams'; -import type {Block} from 'sentry/views/seerExplorer/types'; +import type {Block, SeerExplorerRunId} from 'sentry/views/seerExplorer/types'; import {getExplorerUrl, getLangfuseUrl} from 'sentry/views/seerExplorer/utils'; import type {AssistantBlockProps} from './shared'; @@ -53,7 +53,11 @@ export function AssistantBlock({ ); } -function useBlockFeedback(block: Block, blockIndex: number, runId: number | undefined) { +function useBlockFeedback( + block: Block, + blockIndex: number, + runId: SeerExplorerRunId | undefined +) { const organization = useOrganization(); const [feedbackSubmitted, setFeedbackSubmitted] = useSessionStorage( `seer-explorer-feedback:run-${runId ?? 'null'}:block-${block.id}`, diff --git a/static/app/views/seerExplorer/components/chat/index.tsx b/static/app/views/seerExplorer/components/chat/index.tsx index 3e0318fbd437..4406c6ff16c0 100644 --- a/static/app/views/seerExplorer/components/chat/index.tsx +++ b/static/app/views/seerExplorer/components/chat/index.tsx @@ -3,7 +3,7 @@ import {motion} from 'framer-motion'; import {Container} from '@sentry/scraps/layout'; import {unreachable} from 'sentry/utils/unreachable'; -import type {Block} from 'sentry/views/seerExplorer/types'; +import type {Block, SeerExplorerRunId} from 'sentry/views/seerExplorer/types'; import {AssistantBlock} from './assistant'; import {ToolUseBlock} from './toolUse'; @@ -18,7 +18,7 @@ interface BlockProps { onClick?: () => void; readOnly?: boolean; ref?: React.Ref; - runId?: number; + runId?: SeerExplorerRunId; showThinking?: boolean; } diff --git a/static/app/views/seerExplorer/components/chat/shared.tsx b/static/app/views/seerExplorer/components/chat/shared.tsx index e31916e7815d..6b3348d6eaf0 100644 --- a/static/app/views/seerExplorer/components/chat/shared.tsx +++ b/static/app/views/seerExplorer/components/chat/shared.tsx @@ -8,7 +8,7 @@ import {Link} from '@sentry/scraps/link'; import {Markdown, type MarkdownProps} from '@sentry/scraps/markdown'; import {Heading} from '@sentry/scraps/text'; -import type {Block} from 'sentry/views/seerExplorer/types'; +import type {Block, SeerExplorerRunId} from 'sentry/views/seerExplorer/types'; interface BlockVariantProps { block: Block; @@ -20,7 +20,7 @@ export interface AssistantBlockProps extends BlockVariantProps { blockIndex: number; interactionPending?: boolean; readOnly?: boolean; - runId?: number; + runId?: SeerExplorerRunId; } export interface ToolUseBlockProps extends BlockVariantProps { diff --git a/static/app/views/seerExplorer/components/drawer/useSeerExplorerDrawer.tsx b/static/app/views/seerExplorer/components/drawer/useSeerExplorerDrawer.tsx index 6f14ed9e5588..b650f1b0f65a 100644 --- a/static/app/views/seerExplorer/components/drawer/useSeerExplorerDrawer.tsx +++ b/static/app/views/seerExplorer/components/drawer/useSeerExplorerDrawer.tsx @@ -7,6 +7,7 @@ import {trackAnalytics} from 'sentry/utils/analytics'; import {useOrganization} from 'sentry/utils/useOrganization'; import {ExplorerDrawerContent} from 'sentry/views/seerExplorer/components/drawer/explorerDrawerContent'; import {useSeerExplorerChatDispatch} from 'sentry/views/seerExplorer/seerExplorerChatStateContext'; +import type {SeerExplorerRunId} from 'sentry/views/seerExplorer/types'; import {isSeerExplorerEnabled, usePageReferrer} from 'sentry/views/seerExplorer/utils'; const SEER_EXPLORER_DRAWER_KEY = 'seer-explorer-drawer'; @@ -21,7 +22,7 @@ export type OpenSeerExplorerDrawerOptions = { * Optional run ID to open. If provided, opens an existing session. * Cannot be used together with `startNewRun`. */ - runId?: number; + runId?: SeerExplorerRunId; /** * If true, switches to a new session before opening. * Cannot be used together with `runId`. diff --git a/static/app/views/seerExplorer/components/emptyState.tsx b/static/app/views/seerExplorer/components/emptyState.tsx index 4f7270c579c4..562eced7bb19 100644 --- a/static/app/views/seerExplorer/components/emptyState.tsx +++ b/static/app/views/seerExplorer/components/emptyState.tsx @@ -7,6 +7,7 @@ import {Flex} from '@sentry/scraps/layout'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {IconSeer} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; +import type {SeerExplorerRunId} from 'sentry/views/seerExplorer/types'; const SUGGESTED_QUESTIONS = [ t('Which of my open issues are getting worse, not better?'), @@ -19,7 +20,7 @@ interface EmptyStateProps { isError?: boolean; isLoading?: boolean; onSuggestionClick?: (question: string) => void; - runId?: number | null; + runId?: SeerExplorerRunId | null; } export function EmptyState({ diff --git a/static/app/views/seerExplorer/components/seerExplorerHeader.tsx b/static/app/views/seerExplorer/components/seerExplorerHeader.tsx index bc16aa3cefe0..5bd5ffbfcf12 100644 --- a/static/app/views/seerExplorer/components/seerExplorerHeader.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerHeader.tsx @@ -26,7 +26,10 @@ import { import {t} from 'sentry/locale'; import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; import {useSeerExplorerSessionsQuery} from 'sentry/views/seerExplorer/seerExplorerSessionContext'; -import type {SeerExplorerSidebarPosition} from 'sentry/views/seerExplorer/types'; +import type { + SeerExplorerRunId, + SeerExplorerSidebarPosition, +} from 'sentry/views/seerExplorer/types'; const POSITION_ICON_DIRECTION = { auto: undefined, @@ -44,7 +47,7 @@ const POSITION_ICON_DIRECTION = { interface SeerExplorerHeaderProps { isPipSupported: boolean; isPoppedOut: boolean; - onChangeSession: (runId: number) => void; + onChangeSession: (runId: SeerExplorerRunId) => void; onCopyLinkClick: (() => void) | undefined; onCopySessionClick: (() => void) | undefined; onNewChatClick: () => void; @@ -92,7 +95,7 @@ export function SeerExplorerHeader({ } return ( data?.data.map(session => ({ - value: session.run_id, + value: session.sentry_run_id ?? session.run_id, label: session.title, details: ( { expect(result.current.hasSentInterrupt).toBe(false); }); }); + + it('URL-encodes the runId when building explorer-update URLs', async () => { + // A runId carrying path separators must be encoded so the same-origin + // POST can't traverse to another endpoint. + const maliciousRunId = '../../foo'; + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/seer/explorer-chat/..%2F..%2Ffoo/`, + method: 'GET', + body: {session: {blocks: [], status: 'processing'}}, + }); + const updateMock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/seer/explorer-update/..%2F..%2Ffoo/`, + method: 'POST', + body: {run_id: 123}, + }); + + const {result} = renderHookWithProviders(() => useSeerExplorer(), { + organization, + additionalWrapper: SeerExplorerChatStateProvider, + }); + + act(() => { + result.current.switchToRun(maliciousRunId); + }); + + await waitFor(() => { + expect(result.current.runId).toBe(maliciousRunId); + }); + + act(() => { + result.current.interruptRun(); + }); + + await waitFor(() => { + expect(updateMock).toHaveBeenCalled(); + }); + }); }); describe('Polling Logic', () => { diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx index 2f8af474b517..71c0a0db1cfb 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx @@ -20,6 +20,7 @@ import type { Block, RepoPRState, SeerExplorerResponse, + SeerExplorerRunId, } from 'sentry/views/seerExplorer/types'; import { isSeerExplorerEnabled, @@ -30,12 +31,21 @@ import { type SeerExplorerChatResponse = { message: Block; run_id: number; + sentry_run_id?: string | null; }; type SeerExplorerUpdateResponse = { run_id: number; }; +/** + * Build the explorer-update endpoint URL. `runId` can originate from an + * attacker-controlled `explorerRunId` deep link, so it must be encoded to + * prevent path traversal in the resulting same-origin POST. + */ +const makeExplorerUpdateUrl = (orgSlug: string, runId: SeerExplorerRunId | null) => + `/organizations/${orgSlug}/seer/explorer-update/${encodeURIComponent(String(runId))}/`; + /** Routes where the LLMContext tree provides structured page context. */ const STRUCTURED_CONTEXT_ROUTES = new Set([ '/dashboard/:dashboardId/', @@ -101,7 +111,6 @@ const getOptimisticAssistantTexts = () => [ const makeErrorSeerExplorerData = (errorMessage: string): SeerExplorerResponse => ({ session: { - run_id: undefined, blocks: [ { id: 'error', @@ -170,7 +179,7 @@ export const useSeerExplorer = () => { overrideCtxEngEnable: boolean; pageName: string; query: string; - runId: number | null; + runId: SeerExplorerRunId | null; screenshot: string | undefined; } >({ @@ -209,8 +218,11 @@ export const useSeerExplorer = () => { }, onSuccess: (response, params) => { if (params.runId === null) { - // set run ID if this is a new session - dispatch({type: 'set run id', payload: response.run_id}); + // Prefer the UUID; fall back to the numeric run_id for legacy runs. + dispatch({ + type: 'set run id', + payload: response.sentry_run_id ?? response.run_id, + }); } else { // invalidate the query so fresh data is fetched queryClient.invalidateQueries({ @@ -237,13 +249,17 @@ export const useSeerExplorer = () => { }, }); - const {mutate: userInputMutate} = useMutation({ - mutationFn: async (params: { + const {mutate: userInputMutate} = useMutation< + SeerExplorerUpdateResponse, + RequestError, + { inputId: string; orgSlug: string; - runId: number | null; + runId: SeerExplorerRunId | null; responseData?: Record; - }) => { + } + >({ + mutationFn: async params => { setHasSentInterrupt(false); // Set optimistic status and updated_at to prevent isPolling flicker on new message. @@ -264,8 +280,8 @@ export const useSeerExplorer = () => { : prev ); } - return fetchMutation({ - url: `/organizations/${params.orgSlug}/seer/explorer-update/${params.runId}/`, + return fetchMutation({ + url: makeExplorerUpdateUrl(params.orgSlug, params.runId), method: 'POST', data: { payload: { @@ -305,7 +321,7 @@ export const useSeerExplorer = () => { const {mutate: createPRMutate} = useMutation< SeerExplorerUpdateResponse, RequestError, - {orgSlug: string; runId: number | null; repoName?: string} + {orgSlug: string; runId: SeerExplorerRunId | null; repoName?: string} >({ mutationFn: async params => { setHasSentInterrupt(false); @@ -329,7 +345,7 @@ export const useSeerExplorer = () => { ); } return fetchMutation({ - url: `/organizations/${params.orgSlug}/seer/explorer-update/${params.runId}/`, + url: makeExplorerUpdateUrl(params.orgSlug, params.runId), method: 'POST', data: { payload: { @@ -363,13 +379,13 @@ export const useSeerExplorer = () => { RequestError, { orgSlug: string; - runId: number | null; + runId: SeerExplorerRunId | null; } >({ mutationFn: async params => { setHasSentInterrupt(true); return fetchMutation({ - url: `/organizations/${params.orgSlug}/seer/explorer-update/${params.runId}/`, + url: makeExplorerUpdateUrl(params.orgSlug, params.runId), method: 'POST', data: { payload: { @@ -401,7 +417,7 @@ export const useSeerExplorer = () => { /** Switches to a different run and fetches its latest state. */ const switchToRun = useCallback( - (newRunId: number | null, {onSuccess}: {onSuccess?: () => void} = {}) => { + (newRunId: SeerExplorerRunId | null, {onSuccess}: {onSuccess?: () => void} = {}) => { if (newRunId === runId) { return; } @@ -432,7 +448,11 @@ export const useSeerExplorer = () => { ); const sendMessage = useCallback( - (query: string, explicitInsertIndex?: number, explicitRunId?: number | null) => { + ( + query: string, + explicitInsertIndex?: number, + explicitRunId?: SeerExplorerRunId | null + ) => { if (!orgSlug) { return; } @@ -639,7 +659,6 @@ export const useSeerExplorer = () => { ]; const baseSession = rawSessionData ?? { - run_id: runId ?? undefined, blocks: [], status: 'processing' as const, updated_at: new Date().toISOString(), diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorerPolling.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorerPolling.tsx index d6fe3cdacbe7..1f00b9dfe5d3 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorerPolling.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorerPolling.tsx @@ -5,7 +5,10 @@ import {useApiQuery} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useTimeout} from 'sentry/utils/useTimeout'; import type {PollingState} from 'sentry/views/seerExplorer/seerExplorerChatStateContext'; -import type {SeerExplorerResponse} from 'sentry/views/seerExplorer/types'; +import type { + SeerExplorerResponse, + SeerExplorerRunId, +} from 'sentry/views/seerExplorer/types'; import type {Block} from 'sentry/views/seerExplorer/types'; import { isSeerExplorerEnabled, @@ -36,7 +39,7 @@ const getTimestampAge = (updatedAt: string | undefined): number | null => { }; const getPollingState = ( - runId: number | null, + runId: SeerExplorerRunId | null, sessionData: SeerExplorerResponse['session'] | undefined, isError: boolean, errorStatusCode: number | undefined, @@ -70,7 +73,7 @@ const getPollingState = ( * Called exclusively by `SeerExplorerChatStateProvider`, which dispatches the * derived polling state into context for all consumers. */ -export const useSeerExplorerPolling = ({runId}: {runId: number | null}) => { +export const useSeerExplorerPolling = ({runId}: {runId: SeerExplorerRunId | null}) => { const organization = useOrganization({allowNull: true}); const orgSlug = organization?.slug; const errorPollCountRef = useRef(0); diff --git a/static/app/views/seerExplorer/seerExplorerChatStateContext.tsx b/static/app/views/seerExplorer/seerExplorerChatStateContext.tsx index c00dffcdbff0..b53c9ac89cbf 100644 --- a/static/app/views/seerExplorer/seerExplorerChatStateContext.tsx +++ b/static/app/views/seerExplorer/seerExplorerChatStateContext.tsx @@ -10,6 +10,7 @@ import { import {sessionStorageWrapper} from 'sentry/utils/sessionStorage'; import {useSeerExplorerPolling} from 'sentry/views/seerExplorer/hooks/useSeerExplorerPolling'; +import type {SeerExplorerRunId} from 'sentry/views/seerExplorer/types'; export type PollingState = | 'polling' @@ -22,24 +23,24 @@ type ChatState = { }; type SeerExplorerChatState = { - chatStates: Record; - runId: number | null; + chatStates: Record; + runId: SeerExplorerRunId | null; }; type ChatStateAction = - | {payload: {polling: PollingState; runId: number}; type: 'set polling'} - | {payload: number | null; type: 'set run id'}; + | {payload: {polling: PollingState; runId: SeerExplorerRunId}; type: 'set polling'} + | {payload: SeerExplorerRunId | null; type: 'set run id'}; const RUN_ID_STORAGE_KEY = 'seer-explorer-run-id'; -function readRunIdFromStorage(): number | null { +function readRunIdFromStorage(): SeerExplorerRunId | null { const raw = sessionStorageWrapper.getItem(RUN_ID_STORAGE_KEY); if (raw === null || raw === 'undefined') { return null; } try { const parsed: unknown = JSON.parse(raw); - return typeof parsed === 'number' ? parsed : null; + return typeof parsed === 'number' || typeof parsed === 'string' ? parsed : null; } catch { return null; } @@ -121,7 +122,7 @@ function SeerExplorerChatStatePolling({ }: { children: ReactNode; dispatch: Dispatch; - runId: number | null; + runId: SeerExplorerRunId | null; }) { const {pollingState} = useSeerExplorerPolling({runId}); diff --git a/static/app/views/seerExplorer/types.tsx b/static/app/views/seerExplorer/types.tsx index 7cadb15f954a..c9b744eb1aef 100644 --- a/static/app/views/seerExplorer/types.tsx +++ b/static/app/views/seerExplorer/types.tsx @@ -114,6 +114,7 @@ export interface ExplorerSession { last_triggered_at: string; run_id: number; title: string; + sentry_run_id?: string | null; } export interface Artifact> { @@ -148,6 +149,8 @@ export type PendingUserInput = { input_type: 'file_change_approval' | 'ask_user_question'; }; +export type SeerExplorerRunId = number | string; + export type SeerExplorerResponse = { session: { blocks: Block[]; @@ -156,6 +159,6 @@ export type SeerExplorerResponse = { owner_user_id?: number | null; pending_user_input?: PendingUserInput | null; repo_pr_states?: Record; - run_id?: number; } | null; + sentry_run_id?: string | null; }; diff --git a/static/app/views/seerExplorer/useSeerExplorerContext.tsx b/static/app/views/seerExplorer/useSeerExplorerContext.tsx index 413765da8cda..364053fab145 100644 --- a/static/app/views/seerExplorer/useSeerExplorerContext.tsx +++ b/static/app/views/seerExplorer/useSeerExplorerContext.tsx @@ -32,7 +32,10 @@ import { useSeerExplorerChatDispatch, useSeerExplorerChatState, } from 'sentry/views/seerExplorer/seerExplorerChatStateContext'; -import type {SeerExplorerSidebarPosition} from 'sentry/views/seerExplorer/types'; +import type { + SeerExplorerRunId, + SeerExplorerSidebarPosition, +} from 'sentry/views/seerExplorer/types'; import { useIsSeerExplorerSidebarEnabled, usePageReferrer, @@ -361,7 +364,7 @@ export function SeerExplorerContextProvider({children}: {children: ReactNode}) { // Deep link effect while Seer isn't already showing (the drawer content // handles deep links itself when open or popped out). const deepLinkCallback = useCallback( - (_runId: number) => openSeerExplorer({runId: _runId}), + (_runId: SeerExplorerRunId) => openSeerExplorer({runId: _runId}), [openSeerExplorer] ); diff --git a/static/app/views/seerExplorer/utils.spec.tsx b/static/app/views/seerExplorer/utils.spec.tsx index 881d155e9045..d18e8603663c 100644 --- a/static/app/views/seerExplorer/utils.spec.tsx +++ b/static/app/views/seerExplorer/utils.spec.tsx @@ -1,9 +1,15 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; +import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + import {decodeMetricsQueryParams} from 'sentry/views/explore/metrics/metricQuery'; import {Mode} from 'sentry/views/explore/queryParams/mode'; import {VisualizeFunction} from 'sentry/views/explore/queryParams/visualize'; -import {buildToolLinkUrl} from 'sentry/views/seerExplorer/utils'; +import { + buildToolLinkUrl, + parseRunIdParam, + useSeerExplorerDeepLink, +} from 'sentry/views/seerExplorer/utils'; describe('buildToolLinkUrl', () => { const organization = OrganizationFixture({slug: 'org-slug'}); @@ -74,3 +80,70 @@ describe('buildToolLinkUrl', () => { expect(result).toBeNull(); }); }); + +describe('parseRunIdParam', () => { + it('parses a legacy numeric run ID into a number', () => { + expect(parseRunIdParam('123')).toBe(123); + }); + + it('accepts a UUID run ID as a string', () => { + const uuid = '0fd9e7a2-1c3b-4d5e-8f90-abcdef012345'; + expect(parseRunIdParam(uuid)).toBe(uuid); + }); + + it('rejects values that are neither numeric nor a UUID', () => { + expect(parseRunIdParam('../../foo')).toBeNull(); + expect(parseRunIdParam('not-a-uuid')).toBeNull(); + expect(parseRunIdParam('')).toBeNull(); + expect(parseRunIdParam('12.5')).toBeNull(); + }); +}); + +describe('useSeerExplorerDeepLink', () => { + const UUID = '0fd9e7a2-1c3b-4d5e-8f90-abcdef012345'; + + function renderDeepLink(explorerRunId: string | undefined, enabled = true) { + const callback = jest.fn(); + const {router} = renderHookWithProviders( + () => useSeerExplorerDeepLink({callback, enabled}), + { + initialRouterConfig: { + location: { + pathname: '/issues/', + query: explorerRunId === undefined ? {} : {explorerRunId}, + }, + }, + } + ); + return {callback, router}; + } + + it('opens a UUID run from the deep link and strips the param', async () => { + const {callback, router} = renderDeepLink(UUID); + + await waitFor(() => expect(callback).toHaveBeenCalledWith(UUID)); + expect(router.location.query.explorerRunId).toBeUndefined(); + }); + + it('opens a legacy numeric run as a number', async () => { + const {callback, router} = renderDeepLink('123'); + + await waitFor(() => expect(callback).toHaveBeenCalledWith(123)); + expect(router.location.query.explorerRunId).toBeUndefined(); + }); + + it('ignores a malformed param without navigating or invoking the callback', async () => { + const {callback, router} = renderDeepLink('../../foo'); + + // Nothing valid to do, so the param is left in place and untouched. + await waitFor(() => expect(router.location.query.explorerRunId).toBe('../../foo')); + expect(callback).not.toHaveBeenCalled(); + }); + + it('does nothing when disabled, even with a valid param', async () => { + const {callback, router} = renderDeepLink(UUID, false); + + await waitFor(() => expect(router.location.query.explorerRunId).toBe(UUID)); + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/static/app/views/seerExplorer/utils.tsx b/static/app/views/seerExplorer/utils.tsx index 832964075fb8..bb5e65d34ed3 100644 --- a/static/app/views/seerExplorer/utils.tsx +++ b/static/app/views/seerExplorer/utils.tsx @@ -13,6 +13,7 @@ import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import type {Sort} from 'sentry/utils/discover/fields'; import {SavedQueryDatasets} from 'sentry/utils/discover/types'; import {getRouteStringFromRoutes} from 'sentry/utils/getRouteStringFromRoutes'; +import {isUUID} from 'sentry/utils/string/isUUID'; import {useLocation} from 'sentry/utils/useLocation'; import {useMedia} from 'sentry/utils/useMedia'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -38,6 +39,7 @@ import {VisualizeFunction} from 'sentry/views/explore/queryParams/visualize'; import {makeReplaysPathname} from 'sentry/views/explore/replays/pathnames'; import type { Block, + SeerExplorerRunId, SeerExplorerSidebarPosition, ToolCall, ToolLink, @@ -57,7 +59,7 @@ type ToolFormatter = ( export const makeSeerExplorerQueryKey = ( orgSlug: string, - runId: number | null + runId: SeerExplorerRunId | null ): ApiQueryKey => [ runId ? getApiUrl('/organizations/$organizationIdOrSlug/seer/explorer-chat/$runId/', { @@ -1064,6 +1066,13 @@ function locationToUrl(location: LocationDescriptor): string | null { const RUN_ID_QUERY_PARAM = 'explorerRunId'; +export function parseRunIdParam(value: string): SeerExplorerRunId | null { + if (/^\d+$/.test(value)) { + return Number(value); + } + return isUUID(value) ? value : null; +} + /** * useEffect which listens for run ID query param in the current location. If found, it removes the query param and runs a callback. */ @@ -1071,7 +1080,7 @@ export function useSeerExplorerDeepLink({ callback, enabled = true, }: { - callback: (runId: number) => void; + callback: (runId: SeerExplorerRunId) => void; enabled?: boolean; }) { const location = useLocation(); @@ -1087,12 +1096,14 @@ export function useSeerExplorerDeepLink({ return; } - const parsedRunId = Number(paramValue); - if (!Number.isNaN(parsedRunId)) { - const {[RUN_ID_QUERY_PARAM]: _removed, ...restQuery} = location.query ?? {}; - navigate({...location, query: restQuery}, {replace: true}); - callback(parsedRunId); + const runId = parseRunIdParam(paramValue); + if (runId === null) { + return; } + + const {[RUN_ID_QUERY_PARAM]: _removed, ...restQuery} = location.query ?? {}; + navigate({...location, query: restQuery}, {replace: true}); + callback(runId); }, [location, navigate, callback, enabled]); } @@ -1109,7 +1120,9 @@ export function getLangfuseUrl(runId: number | string): string { return `https://langfuse.getsentry.net/project/clx9kma1k0001iebwrfw4oo0z/sessions/${runId}`; } -export function getExplorerFeedbackOptions(runId: number | null): UseFeedbackOptions { +export function getExplorerFeedbackOptions( + runId: SeerExplorerRunId | null +): UseFeedbackOptions { return { formTitle: 'Seer Agent Feedback', messagePlaceholder: 'How can we make Seer better for you?',