diff --git a/src/sentry/seer/agent/embed_widgets.generated.json b/src/sentry/seer/agent/embed_widgets.generated.json index c4706cfec7a3..3e90f7da2d15 100644 --- a/src/sentry/seer/agent/embed_widgets.generated.json +++ b/src/sentry/seer/agent/embed_widgets.generated.json @@ -1113,6 +1113,136 @@ } ] }, + { + "name": "conversation", + "description": "The ONLY way to reference a single AI agent conversation (Explore > Agents). Use the `conversationId` exactly as the agents conversations API returns it. Include the API-provided `title` when available, and `start`/`end` (the conversation's own first and last span timestamps) so the embed can scope its query instead of scanning the default window. Inline: renders a compact link. Block: renders the conversation transcript with its LLM call, token, cost, and tool totals. Do not duplicate the messages or those totals as text. Never use a markdown link for conversation references.", + "level": ["inline", "block"], + "body": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "minLength": 1 + }, + "projects": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "start": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "end": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "examples": [ + { + "label": "Conversation", + "data": { + "id": "4821", + "title": "Refund request escalated to a human", + "start": "2026-08-25T16:37:12Z", + "end": "2026-08-25T16:39:02Z" + } + } + ], + "featureFlag": "organizations:gen-ai-conversations" + }, + { + "name": "conversationsQuery", + "description": "Preview the AI agent conversations list (Explore > Agents) filtered by a search query. Use this when pointing the user at a SET of conversations — if you have a specific conversation ID, use the `conversation` embed instead. `query` uses span search syntax over gen_ai spans, e.g. \"gen_ai.request.model:gpt-4o\". Negation is not supported. Use `agents` to filter to specific agent names. Inline renders a link; block renders the first five matching conversations with their duration, message count, errors and cost.", + "level": ["inline", "block"], + "body": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "projects": { + "description": "Project IDs. Omit for the \"My Projects\" selection.", + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "environments": { + "type": "array", + "items": { + "type": "string" + } + }, + "statsPeriod": { + "description": "Relative time range, e.g. \"24h\" or \"7d\". Mutually exclusive with start/end.", + "type": "string", + "pattern": "^\\d+[smhdw]$" + }, + "start": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "end": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "query": { + "default": "", + "type": "string" + }, + "agents": { + "description": "Filter to these agent names, as reported by gen_ai.agent.name.", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string", + "minLength": 1 + } + }, + "required": ["query"], + "additionalProperties": false + }, + "examples": [ + { + "label": "Conversations with tool errors", + "data": { + "query": "gen_ai.tool.name:*", + "statsPeriod": "24h", + "title": "Conversations using tools" + } + } + ], + "featureFlag": "organizations:gen-ai-conversations" + }, { "name": "replaysQuery", "description": "Preview the Session Replay list filtered by a search query. Use this when pointing the user at a SET of replays — if you have a specific replay ID, use the `replay` embed instead. `query` uses replay search syntax, e.g. \"user.email:user@example.com\". Inline renders a link; block renders the first five matching replays with their duration, error count and rage clicks.", diff --git a/static/app/components/seer/markdown/__stories__/conversationEmbedStory.spec.tsx b/static/app/components/seer/markdown/__stories__/conversationEmbedStory.spec.tsx new file mode 100644 index 000000000000..b7135e3176d8 --- /dev/null +++ b/static/app/components/seer/markdown/__stories__/conversationEmbedStory.spec.tsx @@ -0,0 +1,106 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import type {Conversation} from 'sentry/views/explore/conversations/hooks/useConversations'; + +import {ConversationEmbedStory} from './conversationEmbedStory'; + +jest.mock('sentry/components/seer/markdown', () => ({ + SeerMarkdown: ({raw}: {raw: string}) =>
{raw}
, +})); + +function createConversation(conversation: Partial): Conversation { + return { + conversationId: 'conv-1', + duration: 1000, + endTimestamp: Date.UTC(2026, 7, 25, 16, 39, 2), + errors: 0, + firstInput: 'Where is my refund?', + generationDuration: 800, + inputTokens: 100, + lastOutput: 'Escalating to a human.', + llmCalls: 3, + outputTokens: 50, + projectId: 11, + startTimestamp: Date.UTC(2026, 7, 25, 16, 37, 12), + title: null, + toolCalls: 1, + toolErrors: 0, + toolNames: ['lookup_order'], + totalCost: 0.02, + totalTokens: 150, + traceCount: 1, + traceIds: ['11111111111111111111111111111111'], + user: null, + ...conversation, + }; +} + +describe('ConversationEmbedStory', () => { + it('prefers a titled conversation and passes its ISO time bounds', async () => { + const untitled = createConversation({conversationId: 'untitled-conversation'}); + const titled = createConversation({ + conversationId: 'titled-conversation', + title: 'Refund request escalated to a human', + }); + const conversationsRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/agents/conversations/', + body: [untitled, titled], + match: [ + MockApiClient.matchQuery({ + project: [-1], + per_page: 25, + statsPeriod: '14d', + }), + ], + }); + + render(); + + const renderedMarkdown = await screen.findByLabelText('Rendered markdown'); + expect(renderedMarkdown).toHaveTextContent(titled.conversationId); + expect(renderedMarkdown).toHaveTextContent('Refund request escalated to a human'); + expect(renderedMarkdown).toHaveTextContent( + new Date(titled.startTimestamp).toISOString() + ); + expect(renderedMarkdown).toHaveTextContent( + new Date(titled.endTimestamp).toISOString() + ); + expect(renderedMarkdown).not.toHaveTextContent(untitled.conversationId); + expect(conversationsRequest).toHaveBeenCalled(); + }); + + it('falls back to the most recent conversation when none has a title', async () => { + const older = createConversation({ + conversationId: 'older-conversation', + endTimestamp: Date.UTC(2026, 7, 24, 10, 0, 0), + }); + const newer = createConversation({ + conversationId: 'newer-conversation', + endTimestamp: Date.UTC(2026, 7, 26, 10, 0, 0), + }); + // The endpoint orders by relevance, so the newest row is not necessarily first. + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/agents/conversations/', + body: [older, newer], + }); + + render(); + + const renderedMarkdown = await screen.findByLabelText('Rendered markdown'); + expect(renderedMarkdown).toHaveTextContent(newer.conversationId); + expect(renderedMarkdown).not.toHaveTextContent(older.conversationId); + }); + + it('renders a message when the organization has no conversations', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/agents/conversations/', + body: [], + }); + + render(); + + expect( + await screen.findByText('No conversation is available for this organization.') + ).toBeInTheDocument(); + }); +}); diff --git a/static/app/components/seer/markdown/__stories__/conversationEmbedStory.tsx b/static/app/components/seer/markdown/__stories__/conversationEmbedStory.tsx new file mode 100644 index 000000000000..5098837cad81 --- /dev/null +++ b/static/app/components/seer/markdown/__stories__/conversationEmbedStory.tsx @@ -0,0 +1,78 @@ +import {useQuery} from '@tanstack/react-query'; + +import {Text} from '@sentry/scraps/text'; + +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {ALL_ACCESS_PROJECTS} from 'sentry/components/pageFilters/constants'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import type {Conversation} from 'sentry/views/explore/conversations/hooks/useConversations'; + +import {EmbedStory, EmbedVariant} from './embedStory'; + +/** Enough rows to find one with a title without paging the whole list. */ +const STORY_CONVERSATION_LIMIT = 25; + +/** + * The schema's `start`/`end` are ISO strings, but a conversation row carries + * epoch milliseconds. + */ +function toIsoTimestamp(timestamp: number | null | undefined): string | undefined { + return timestamp ? new Date(timestamp).toISOString() : undefined; +} + +/** + * `useConversations` reads its filters from the router, which a story has no + * business driving, so the list is fetched directly -- the same call the + * `conversationsQuery` block makes. + */ +export function ConversationEmbedStory() { + const organization = useOrganization(); + const {data, isError, isPending} = useQuery({ + ...apiOptions.as()( + '/organizations/$organizationIdOrSlug/agents/conversations/', + { + path: {organizationIdOrSlug: organization.slug}, + query: { + project: [ALL_ACCESS_PROJECTS], + per_page: STORY_CONVERSATION_LIMIT, + statsPeriod: '14d', + }, + staleTime: 30_000, + } + ), + retry: false, + }); + + // The endpoint orders by relevance rather than recency, and a titled + // conversation shows off the embed better than an untitled one. + const conversations = (data ?? []).toSorted((a, b) => b.endTimestamp - a.endTimestamp); + const conversation = conversations.find(row => row.title) ?? conversations[0]; + + return ( + + {isPending ? ( + + ) : isError ? ( + Unable to load a conversation example. + ) : conversation ? ( + + ) : ( + No conversation is available for this organization. + )} + + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx new file mode 100644 index 000000000000..bbd226047bb1 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversation.spec.tsx @@ -0,0 +1,175 @@ +import {act, screen, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {PageFiltersStore} from 'sentry/components/pageFilters/store'; +import { + getEmbedLinkHref, + renderEmbed, +} from 'sentry/components/seer/markdown/embeds/components/resourceEmbedTestUtils'; + +const CONVERSATION_ID = 'conv-1'; +const DETAIL_URL = `/organizations/org-slug/agents/conversations/${CONVERSATION_ID}/`; + +/** Shaped as the conversation detail endpoint returns it: flat spans. */ +function spanFixture(overrides: Record) { + return { + 'gen_ai.conversation.id': CONVERSATION_ID, + parent_span: 'parent-1', + project: 'test-project', + 'project.id': 1, + 'span.status': 'ok', + trace: 'trace-1', + 'gen_ai.operation.type': 'ai_client', + ...overrides, + }; +} + +function searchParams(href: string) { + return new URLSearchParams(href.split('?')[1] ?? ''); +} + +describe('conversation embed', () => { + beforeAll(async () => { + // The block is behind `lazy()`. Compiling its module graph on first render + // costs more than a `findBy*` will wait for, so pay it here instead of + // inside the first block assertion. + await import('./conversationBlock'); + }, 60_000); + + beforeEach(() => { + MockApiClient.clearMockResponses(); + act(() => { + PageFiltersStore.reset(); + PageFiltersStore.init(); + }); + }); + + it('links to the conversation detail view inline', () => { + const href = getEmbedLinkHref('conversation', 'Refund request escalated', { + id: CONVERSATION_ID, + title: 'Refund request escalated', + projects: ['1'], + start: '2026-08-25T16:37:12Z', + end: '2026-08-25T16:39:02Z', + }); + + expect(href.split('?')[0]).toBe( + `/organizations/org-slug/explore/agents/conversations/${CONVERSATION_ID}/` + ); + const params = searchParams(href); + // The detail view scopes its span query to this window, so it is padded + // an hour either side of the conversation's own timestamps. + expect(params.get('start')).toBe('2026-08-25T15:37:12.000Z'); + expect(params.get('end')).toBe('2026-08-25T17:39:02.000Z'); + expect(params.getAll('project')).toEqual(['1']); + expect(params.get('referrer')).toBe('seer-conversation-embed'); + }); + + it('falls back to the id when the tag carries no title', () => { + const href = getEmbedLinkHref('conversation', `Conversation ${CONVERSATION_ID}`, { + id: CONVERSATION_ID, + }); + + expect(href).toBe( + `/organizations/org-slug/explore/agents/conversations/${CONVERSATION_ID}/?referrer=seer-conversation-embed` + ); + }); + + it('renders the aggregates but not the transcript', async () => { + const request = MockApiClient.addMockResponse({ + url: DETAIL_URL, + body: { + conversationId: CONVERSATION_ID, + title: 'Out of memory investigation', + spans: [ + spanFixture({ + span_id: 'span-a', + 'span.name': 'first turn', + 'precise.start_ts': 1000, + 'precise.finish_ts': 1000.5, + 'gen_ai.request.messages': JSON.stringify([ + {role: 'user', content: 'Why did the job fail?'}, + ]), + 'gen_ai.response.text': 'The worker ran out of memory.', + 'gen_ai.usage.total_tokens': 1200, + }), + ], + }, + }); + + renderEmbed({ + name: 'conversation', + data: { + id: CONVERSATION_ID, + title: 'Stale title', + start: '2026-08-25T16:37:12Z', + end: '2026-08-25T16:39:02Z', + }, + }); + + // The aggregates bar renders its labels while loading too, so the API + // title -- which only arrives with the response -- is the loaded signal. + // The API title also wins over whatever the model wrote into the tag. + expect( + await screen.findByRole('link', {name: 'Out of memory investigation'}) + ).toBeInTheDocument(); + expect(screen.queryByText('Stale title')).not.toBeInTheDocument(); + + expect(screen.getByText('LLM Calls')).toBeInTheDocument(); + expect(screen.getByText('Errors')).toBeInTheDocument(); + expect(screen.getByText('Tokens')).toBeInTheDocument(); + expect(screen.getByText('Cost')).toBeInTheDocument(); + + // The embed renders inside an agent conversation, so it deliberately shows + // the totals only -- a nested transcript reads as part of the answer. + expect(screen.queryByText('The worker ran out of memory.')).not.toBeInTheDocument(); + expect(screen.queryByText('Why did the job fail?')).not.toBeInTheDocument(); + + await waitFor(() => { + expect(request).toHaveBeenCalledWith( + DETAIL_URL, + expect.objectContaining({ + query: expect.objectContaining({ + // The tag's ISO timestamps scope the query instead of the + // default page-filter window. + start: '2026-08-25T15:37:12.000Z', + end: '2026-08-25T17:39:02.000Z', + }), + }) + ); + }); + }); + + it("scopes the fetch to the tag's projects, not the host page's", async () => { + const request = MockApiClient.addMockResponse({ + url: DETAIL_URL, + body: {conversationId: CONVERSATION_ID, title: 'Scoped', spans: []}, + }); + + // The page the embed is rendered into is filtered to a different project. + act(() => { + PageFiltersStore.updateProjects([99], null); + }); + + renderEmbed({ + name: 'conversation', + data: {id: CONVERSATION_ID, projects: ['7']}, + }); + + await waitFor(() => { + expect(request).toHaveBeenCalledWith( + DETAIL_URL, + expect.objectContaining({ + query: expect.objectContaining({project: [7]}), + }) + ); + }); + }); + + it('shows an error when the conversation cannot be loaded', async () => { + MockApiClient.addMockResponse({url: DETAIL_URL, statusCode: 500, body: {}}); + + renderEmbed({name: 'conversation', data: {id: CONVERSATION_ID}}); + + expect(await screen.findByText('Unable to load conversation.')).toBeInTheDocument(); + }); +}); diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversation.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversation.tsx new file mode 100644 index 000000000000..569e64aa6499 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversation.tsx @@ -0,0 +1,18 @@ +import {lazy} from 'react'; + +import {LazyLoad} from 'sentry/components/lazyLoad'; +import {defineSeerEmbed} from 'sentry/components/seer/markdown/embeds/utils'; + +import {ConversationLink} from './conversationLink'; + +const LazyConversationBlock = lazy(() => import('./conversationBlock')); + +export const Conversation = defineSeerEmbed({ + name: 'conversation', + render(props, level) { + if (level === 'block') { + return ; + } + return ; + }, +}); diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx new file mode 100644 index 000000000000..2ba9c29648a6 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversationBlock.tsx @@ -0,0 +1,64 @@ +import {Text} from '@sentry/scraps/text'; + +import {QueryEmbedCard} from 'sentry/components/seer/markdown/embeds/components/queryEmbed/queryEmbedCard'; +import {t} from 'sentry/locale'; +import {ConversationAggregatesBar} from 'sentry/views/explore/conversations/components/conversationSummary'; +import {useConversation} from 'sentry/views/explore/conversations/hooks/useConversation'; + +import {ConversationLink, type ConversationData} from './conversationLink'; + +function toTimestampMs(isoTimestamp: string | undefined): number | undefined { + if (!isoTimestamp) { + return undefined; + } + const parsed = Date.parse(isoTimestamp); + return Number.isNaN(parsed) ? undefined : parsed; +} + +/** + * Scopes the fetch to the conversation's own projects rather than the host + * page's filters, which have nothing to do with the conversation the tag names. + * The ids come from the model, so anything non-numeric is dropped instead of + * being sent as `NaN`. + */ +function toProjectIds(projects: ConversationData['projects']): number[] | undefined { + if (!projects?.length) { + return undefined; + } + const ids = projects.map(Number).filter(id => Number.isFinite(id)); + return ids.length > 0 ? ids : undefined; +} + +/** + * Deliberately shows the conversation's totals and not its transcript: the + * embed is itself rendered inside an agent conversation, so a nested transcript + * reads as part of the surrounding answer. The link goes to the full detail + * view for anyone who wants the messages. + */ +export default function ConversationBlock({data}: {data: ConversationData}) { + const {nodes, isLoading, error, title} = useConversation({ + conversationId: data.id, + projects: toProjectIds(data.projects), + startTimestamp: toTimestampMs(data.start), + endTimestamp: toTimestampMs(data.end), + }); + + return ( + } + testId="seer-conversation-embed" + > + {error ? ( + {t('Unable to load conversation.')} + ) : !isLoading && nodes.length === 0 ? ( + {t('No messages in this conversation')} + ) : ( + + )} + + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/conversation/conversationLink.tsx b/static/app/components/seer/markdown/embeds/components/conversation/conversationLink.tsx new file mode 100644 index 000000000000..3e5445bd2d1c --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/conversation/conversationLink.tsx @@ -0,0 +1,73 @@ +import {ResourceLink} from 'sentry/components/seer/markdown/embeds/components/resourceLink'; +import type {EmbedOutput} from 'sentry/components/seer/markdown/embeds/utils'; +import {IconChat} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import { + CONVERSATIONS_DETAIL_SUB_PATH, + EXPLORE_AGENTS_SUB_PATH, +} from 'sentry/views/explore/conversations/settings'; + +export type ConversationData = EmbedOutput<'conversation'>; + +/** + * The detail view scopes its span query to the URL's time range, so the window + * is padded either side of the conversation's own timestamps -- the same hour + * `getConversationDetailUrl` pads by for a row in the conversations table. + */ +const CONVERSATION_WINDOW_PADDING_MS = 60 * 60 * 1000; + +/** + * `getConversationDetailUrl` needs a full `Conversation` row, which an embed + * never has -- the tag carries an id and, at best, the conversation's first and + * last span timestamps. Build the same path from those instead. + */ +export function getConversationHref( + data: ConversationData, + organizationSlug: string, + referrer = 'seer-conversation-embed' +): string { + const basePath = `/organizations/${organizationSlug}/explore/${EXPLORE_AGENTS_SUB_PATH}/${CONVERSATIONS_DETAIL_SUB_PATH}/${encodeURIComponent(data.id)}/`; + + const params = new URLSearchParams(); + if (data.start) { + params.set( + 'start', + new Date(Date.parse(data.start) - CONVERSATION_WINDOW_PADDING_MS).toISOString() + ); + } + if (data.end) { + params.set( + 'end', + new Date(Date.parse(data.end) + CONVERSATION_WINDOW_PADDING_MS).toISOString() + ); + } + for (const project of data.projects ?? []) { + params.append('project', String(project)); + } + params.set('referrer', referrer); + + return normalizeUrl(`${basePath}?${params.toString()}`); +} + +interface ConversationLinkProps { + data: ConversationData; + /** + * Overrides the tag's title. The block passes the API-provided title once it + * has loaded, which is fresher than whatever the model wrote into the tag. + */ + title?: string | null; +} + +export function ConversationLink({data, title}: ConversationLinkProps) { + const organization = useOrganization(); + + return ( + + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/conversationsQuery.spec.tsx b/static/app/components/seer/markdown/embeds/components/conversationsQuery.spec.tsx new file mode 100644 index 000000000000..bb016ba8708f --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/conversationsQuery.spec.tsx @@ -0,0 +1,180 @@ +import {act, screen, waitFor, within} from 'sentry-test/reactTestingLibrary'; + +import {PageFiltersStore} from 'sentry/components/pageFilters/store'; +import { + getEmbedLinkHref, + renderEmbed, +} from 'sentry/components/seer/markdown/embeds/components/resourceEmbedTestUtils'; + +const LIST_URL = '/organizations/org-slug/agents/conversations/'; + +/** Shaped as the list endpoint returns it: `firstInput` may be content parts. */ +function conversationFixture(overrides: Record = {}) { + return { + conversationId: '8f0e1f6a-1f2b-4d3c-9e5a-0b1c2d3e4f5a', + title: 'Refund request escalated', + firstInput: [{type: 'text', text: 'I want a refund'}], + lastOutput: 'Escalating to a human', + duration: 120_000, + generationDuration: 4200, + llmCalls: 3, + toolCalls: 2, + toolErrors: 0, + toolNames: ['search'], + errors: 1, + inputTokens: 100, + outputTokens: 200, + totalTokens: 300, + totalCost: 0.42, + startTimestamp: 1_700_000_000_000, + endTimestamp: 1_700_000_120_000, + traceCount: 1, + traceIds: ['trace-1'], + projectId: 1, + user: {email: 'user@example.com', id: '1', ip_address: null, username: null}, + ...overrides, + }; +} + +function searchParams(href: string) { + return new URLSearchParams(href.split('?')[1] ?? ''); +} + +describe('conversationsQuery embed', () => { + beforeAll(async () => { + // The block is behind `lazy()`; compile its module graph up front rather + // than inside the first `findBy*`. + await import('./conversationsQueryBlock'); + }, 60_000); + + beforeEach(() => { + MockApiClient.clearMockResponses(); + act(() => { + PageFiltersStore.reset(); + PageFiltersStore.init(); + }); + }); + + it('links to the agents list inline', () => { + const href = getEmbedLinkHref('conversationsQuery', 'Conversations using tools', { + query: 'gen_ai.tool.name:*', + statsPeriod: '24h', + projects: ['1', '2'], + environments: ['prod'], + agents: ['support-bot', 'triage-bot'], + title: 'Conversations using tools', + }); + + expect(href.split('?')[0]).toBe('/organizations/org-slug/explore/agents/'); + const params = searchParams(href); + expect(params.get('query')).toBe('gen_ai.tool.name:*'); + expect(params.get('statsPeriod')).toBe('24h'); + expect(params.getAll('project')).toEqual(['1', '2']); + expect(params.getAll('environment')).toEqual(['prod']); + // The list reads `agent` as one comma-separated value. + expect(params.get('agent')).toBe('support-bot,triage-bot'); + expect(params.get('referrer')).toBe('seer-conversations-query-embed'); + }); + + it('falls back to a generic title', () => { + const href = getEmbedLinkHref('conversationsQuery', 'Conversation search', { + query: '', + }); + + expect(href).toContain('/organizations/org-slug/explore/agents/'); + }); + + it('previews matching conversations', async () => { + const request = MockApiClient.addMockResponse({ + url: LIST_URL, + body: [ + conversationFixture({ + conversationId: 'older', + title: 'Older conversation', + endTimestamp: 1_600_000_000_000, + }), + conversationFixture(), + ], + }); + + renderEmbed({ + name: 'conversationsQuery', + data: { + query: 'gen_ai.request.model:gpt-4o', + statsPeriod: '24h', + agents: ['support-bot'], + }, + }); + + expect(await screen.findByText('Refund request escalated')).toBeInTheDocument(); + + const row = screen.getByRole('row', {name: /Refund request escalated/}); + // A UUID id is shown as a short prefix. + expect(within(row).getByText('8f0e1f6a')).toBeInTheDocument(); + expect(within(row).getByText('user@example.com')).toBeInTheDocument(); + expect(within(row).getByText('3')).toBeInTheDocument(); + expect(within(row).getByText('1')).toBeInTheDocument(); + expect(within(row).getByText('$0.42')).toBeInTheDocument(); + + // Newest-first, the way the list view orders its rows. + const rows = screen.getAllByRole('row'); + expect(rows[1]).toHaveTextContent('Refund request escalated'); + expect(rows[2]).toHaveTextContent('Older conversation'); + + await waitFor(() => { + expect(request).toHaveBeenCalledWith( + LIST_URL, + expect.objectContaining({ + query: expect.objectContaining({ + // The agent filter is a URL param on the list view, but part of + // the span query when the endpoint is called directly. + query: expect.stringContaining('support-bot'), + statsPeriod: '24h', + per_page: 5, + }), + }) + ); + }); + expect(request.mock.calls[0][1].query.query).toContain('gen_ai.request.model:gpt-4o'); + }); + + it('links each row to its conversation in a new tab', async () => { + MockApiClient.addMockResponse({url: LIST_URL, body: [conversationFixture()]}); + + renderEmbed({name: 'conversationsQuery', data: {query: ''}}); + + const link = await screen.findByRole('link', {name: 'Refund request escalated'}); + + // A new tab keeps the answer the embed is rendered into on screen. + expect(link).toHaveAttribute('target', '_blank'); + const href = link.getAttribute('href') ?? ''; + expect(href.split('?')[0]).toBe( + '/organizations/org-slug/explore/agents/conversations/8f0e1f6a-1f2b-4d3c-9e5a-0b1c2d3e4f5a/' + ); + const params = searchParams(href); + // The row's own timestamps, padded the same hour the detail view expects. + expect(params.get('start')).toBe('2023-11-14T21:13:20.000Z'); + expect(params.get('end')).toBe('2023-11-14T23:15:20.000Z'); + expect(params.getAll('project')).toEqual(['1']); + expect(params.get('referrer')).toBe('seer-conversations-query-embed'); + }); + + it('flattens a content-part first input when there is no title', async () => { + MockApiClient.addMockResponse({ + url: LIST_URL, + body: [conversationFixture({title: null})], + }); + + renderEmbed({name: 'conversationsQuery', data: {query: ''}}); + + expect(await screen.findByText('I want a refund')).toBeInTheDocument(); + }); + + it('shows an empty state when nothing matches', async () => { + MockApiClient.addMockResponse({url: LIST_URL, body: []}); + + renderEmbed({name: 'conversationsQuery', data: {query: 'gen_ai.tool.name:*'}}); + + expect(await screen.findByText('No matching conversations')).toBeInTheDocument(); + }); +}); diff --git a/static/app/components/seer/markdown/embeds/components/conversationsQuery.tsx b/static/app/components/seer/markdown/embeds/components/conversationsQuery.tsx new file mode 100644 index 000000000000..45583d2d403b --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/conversationsQuery.tsx @@ -0,0 +1,18 @@ +import {lazy} from 'react'; + +import {LazyLoad} from 'sentry/components/lazyLoad'; +import {defineSeerEmbed} from 'sentry/components/seer/markdown/embeds/utils'; + +import {ConversationsQueryLink} from './conversationsQueryLink'; + +const LazyConversationsQueryBlock = lazy(() => import('./conversationsQueryBlock')); + +export const ConversationsQuery = defineSeerEmbed({ + name: 'conversationsQuery', + render(props, level) { + if (level === 'block') { + return ; + } + return ; + }, +}); diff --git a/static/app/components/seer/markdown/embeds/components/conversationsQueryBlock.tsx b/static/app/components/seer/markdown/embeds/components/conversationsQueryBlock.tsx new file mode 100644 index 000000000000..d76401e26775 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/conversationsQueryBlock.tsx @@ -0,0 +1,222 @@ +import {useQuery} from '@tanstack/react-query'; + +import {Flex, Stack} from '@sentry/scraps/layout'; +import {ExternalLink} from '@sentry/scraps/link'; +import {Text} from '@sentry/scraps/text'; + +import {Count} from 'sentry/components/count'; +import {normalizeDateTimeParams} from 'sentry/components/pageFilters/parse'; +import {PerformanceDuration} from 'sentry/components/performanceDuration'; +import {QueryEmbedCard} from 'sentry/components/seer/markdown/embeds/components/queryEmbed/queryEmbedCard'; +import {QUERY_EMBED_ROW_LIMIT} from 'sentry/components/seer/markdown/embeds/components/queryEmbed/queryEmbedConstants'; +import { + QueryEmbedTable, + type QueryEmbedColumn, +} from 'sentry/components/seer/markdown/embeds/components/queryEmbed/queryEmbedTable'; +import {toPageFilters} from 'sentry/components/seer/markdown/embeds/components/queryEmbedParams'; +import {t} from 'sentry/locale'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {markdownToPlainText} from 'sentry/utils/marked/marked'; +import {ellipsize} from 'sentry/utils/string/ellipsize'; +import {isUUID} from 'sentry/utils/string/isUUID'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import type { + Conversation, + ConversationUser, +} from 'sentry/views/explore/conversations/hooks/useConversations'; +import {LLMCosts} from 'sentry/views/insights/pages/agents/components/llmCosts'; + +import {getConversationHref} from './conversation/conversationLink'; +import { + combineAgentQuery, + ConversationsQueryLink, + type ConversationsQueryData, +} from './conversationsQueryLink'; + +/** + * The list endpoint's raw rows. `useConversations` flattens the two content + * fields before handing them out, and this block calls the endpoint directly + * (that hook takes its filters from the router), so it flattens them itself. + */ +type ConversationApiRow = Omit & { + firstInput?: Array<{text: string; type: string}> | string | null; + lastOutput?: Array<{text: string; type: string}> | string | null; +}; + +/** A preview line is one table cell wide, so it is cut well short of the table's limit. */ +const PREVIEW_MAX_CHARS = 200; + +function flattenContent(content: ConversationApiRow['firstInput']): string | null { + if (typeof content === 'string') { + return content; + } + return content?.find(part => part.type === 'text')?.text ?? null; +} + +/** + * A title is model-written markdown and a first input is whatever the user + * typed, so both are flattened to a single line before they go in a cell. + */ +function getConversationLabel(row: ConversationApiRow): string | null { + const raw = row.title ?? flattenContent(row.firstInput); + if (!raw) { + return null; + } + const plainText = ellipsize( + markdownToPlainText(raw).replace(/\s+/g, ' ').trim(), + PREVIEW_MAX_CHARS + ); + return plainText.length > 0 ? plainText : null; +} + +/** `useConversations` renders the SDK's literal "none" as no user at all. */ +function getUserLabel(user: ConversationUser | null): string | null { + const fields = [user?.email, user?.username, user?.ip_address, user?.id]; + return fields.find(value => value && value.toLowerCase() !== 'none') ?? null; +} + +/** A row carries epoch milliseconds; the detail URL wants ISO timestamps. */ +function toIsoTimestamp(timestamp: number | null | undefined): string | undefined { + return timestamp ? new Date(timestamp).toISOString() : undefined; +} + +function getConversationIdLabel(conversationId: string): string { + // UUIDs are long and opaque, so show a short prefix; other id formats + // (e.g. `resp_...`, `slack:1234`) are already short enough. + return isUUID(conversationId) ? conversationId.slice(0, 8) : conversationId; +} + +/** + * Opens in a new tab so following a row cannot replace the page the embed is + * rendered into -- the answer above it would be lost. `getConversationHref` + * builds the same detail URL the `conversation` embed links to. + */ +function ConversationCell({row}: {row: ConversationApiRow}) { + const organization = useOrganization(); + const label = getConversationLabel(row); + const user = getUserLabel(row.user); + + return ( + + + + {label ?? t('Untitled conversation')} + + + + + {getConversationIdLabel(row.conversationId)} + + {user ? ( + + {user} + + ) : null} + + + ); +} + +const COLUMNS: Array> = [ + { + key: 'conversation', + label: t('Conversation'), + render: row => , + }, + { + key: 'duration', + label: t('Duration'), + // The generation duration, not the wall-clock span of the conversation: + // a conversation can sit idle for hours between two messages. + render: row => ( + + + + ), + }, + { + key: 'messages', + label: t('Messages'), + render: row => ( + + + + ), + }, + { + key: 'errors', + label: t('Errors'), + render: row => ( + 0 ? 'danger' : undefined}> + + + ), + }, + { + key: 'cost', + label: t('Cost'), + render: row => ( + + + + ), + }, +]; + +export default function ConversationsQueryBlock({data}: {data: ConversationsQueryData}) { + const organization = useOrganization(); + const selection = toPageFilters(data); + + const conversationsQuery = useQuery({ + ...apiOptions.as()( + '/organizations/$organizationIdOrSlug/agents/conversations/', + { + path: {organizationIdOrSlug: organization.slug}, + query: { + query: combineAgentQuery(data.query, data.agents), + project: selection.projects, + environment: selection.environments, + per_page: QUERY_EMBED_ROW_LIMIT, + ...normalizeDateTimeParams(selection.datetime), + }, + staleTime: 30_000, + } + ), + retry: false, + }); + + // The endpoint orders by relevance rather than recency; the list view sorts + // newest-first before rendering, so the preview shows the same five rows. + const rows = (conversationsQuery.data ?? []).toSorted( + (a, b) => b.endTimestamp - a.endTimestamp + ); + + return ( + } + query={data.query} + testId="seer-conversations-query-embed" + > + row.conversationId} + rows={rows} + /> + + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/conversationsQueryLink.tsx b/static/app/components/seer/markdown/embeds/components/conversationsQueryLink.tsx new file mode 100644 index 000000000000..210a96be6a65 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/conversationsQueryLink.tsx @@ -0,0 +1,67 @@ +import queryString from 'query-string'; + +import {ResourceLink} from 'sentry/components/seer/markdown/embeds/components/resourceLink'; +import type {EmbedOutput} from 'sentry/components/seer/markdown/embeds/utils'; +import {IconChat} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import type {Organization} from 'sentry/types/organization'; +import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {EXPLORE_AGENTS_SUB_PATH} from 'sentry/views/explore/conversations/settings'; +import {getAgentNamesFilter} from 'sentry/views/insights/pages/agents/utils/query'; + +export type ConversationsQueryData = EmbedOutput<'conversationsQuery'>; + +/** + * The agents list keeps the agent filter in its own `agent` param and folds it + * into the span query itself only when it calls the API -- see + * `useCombinedQuery`. Mirror both halves so the link and the block preview + * below it are filtered the same way. + */ +export function combineAgentQuery(query: string, agents?: string[]): string { + const agentQuery = getAgentNamesFilter(agents ?? []); + if (!agentQuery) { + return query; + } + if (!query) { + return agentQuery; + } + return `(${agentQuery}) and (${query})`; +} + +function getConversationsQueryHref( + data: ConversationsQueryData, + organization: Organization +): string { + const {query, agents, projects, environments, statsPeriod, start, end} = data; + + return queryString.stringifyUrl({ + url: normalizeUrl( + `/organizations/${organization.slug}/explore/${EXPLORE_AGENTS_SUB_PATH}/` + ), + query: { + query, + project: projects, + environment: environments, + statsPeriod, + start, + end, + // The list reads `agent` as a single comma-separated value, not repeated + // params. + agent: agents?.length ? agents.join(',') : undefined, + referrer: 'seer-conversations-query-embed', + }, + }); +} + +export function ConversationsQueryLink({data}: {data: ConversationsQueryData}) { + const organization = useOrganization(); + + return ( + + ); +} diff --git a/static/app/components/seer/markdown/embeds/index.ts b/static/app/components/seer/markdown/embeds/index.ts index ca31b55e1875..f54556af2dd1 100644 --- a/static/app/components/seer/markdown/embeds/index.ts +++ b/static/app/components/seer/markdown/embeds/index.ts @@ -2,6 +2,8 @@ import {AgentWriteApprovalEmbed} from './components/agentWriteApproval'; import {Alert} from './components/alert/alert'; import {Autofix, AutofixRef} from './components/autofix'; import {Chart} from './components/chart'; +import {Conversation} from './components/conversation/conversation'; +import {ConversationsQuery} from './components/conversationsQuery'; import {Dashboard} from './components/dashboard'; import {Docs} from './components/docs'; import {Dsn} from './components/dsn'; @@ -29,6 +31,8 @@ const embeds = [ Autofix, AutofixRef, Chart, + Conversation, + ConversationsQuery, Dashboard, Docs, Dsn, diff --git a/static/app/components/seer/markdown/embeds/schemas.ts b/static/app/components/seer/markdown/embeds/schemas.ts index 8f9f369d7ec3..dd567a81a3d1 100644 --- a/static/app/components/seer/markdown/embeds/schemas.ts +++ b/static/app/components/seer/markdown/embeds/schemas.ts @@ -680,6 +680,70 @@ export const SEER_EMBED_SCHEMAS = { }, ], }, + conversation: { + description: + 'The ONLY way to reference a single AI agent conversation (Explore > Agents). ' + + 'Use the `conversationId` exactly as the agents conversations API returns it. ' + + 'Include the API-provided `title` when available, and `start`/`end` (the ' + + "conversation's own first and last span timestamps) so the embed can scope " + + 'its query instead of scanning the default window. ' + + 'Inline: renders a compact link. ' + + 'Block: renders the conversation transcript with its LLM call, token, cost, ' + + 'and tool totals. Do not duplicate the messages or those totals as text. ' + + 'Never use a markdown link for conversation references.', + featureFlag: 'organizations:gen-ai-conversations', + level: ['inline', 'block'], + schema: z.object({ + id: z.string().min(1), + title: z.string().min(1).optional(), + projects: z.array(idString).optional(), + start: isoTimestampSchema.optional(), + end: isoTimestampSchema.optional(), + }), + examples: [ + { + label: 'Conversation', + data: { + id: '4821', + title: 'Refund request escalated to a human', + start: '2026-08-25T16:37:12Z', + end: '2026-08-25T16:39:02Z', + }, + }, + ], + }, + conversationsQuery: { + description: + 'Preview the AI agent conversations list (Explore > Agents) filtered by a ' + + 'search query. Use this when pointing the user at a SET of conversations — ' + + 'if you have a specific conversation ID, use the `conversation` embed instead. ' + + '`query` uses span search syntax over gen_ai spans, e.g. ' + + '"gen_ai.request.model:gpt-4o". Negation is not supported. ' + + 'Use `agents` to filter to specific agent names. ' + + 'Inline renders a link; block renders the first five matching conversations ' + + 'with their duration, message count, errors and cost.', + featureFlag: 'organizations:gen-ai-conversations', + level: ['inline', 'block'], + schema: z.object({ + ...pageFilterFields, + query: z.string().default(''), + agents: z + .array(z.string()) + .optional() + .describe('Filter to these agent names, as reported by gen_ai.agent.name.'), + title: z.string().min(1).optional(), + }), + examples: [ + { + label: 'Conversations with tool errors', + data: { + query: 'gen_ai.tool.name:*', + statsPeriod: '24h', + title: 'Conversations using tools', + }, + }, + ], + }, replaysQuery: { description: 'Preview the Session Replay list filtered by a search query. ' + diff --git a/static/app/components/seer/markdown/seerMarkdown.mdx b/static/app/components/seer/markdown/seerMarkdown.mdx index 76226ee2ccf8..7280e8c99e90 100644 --- a/static/app/components/seer/markdown/seerMarkdown.mdx +++ b/static/app/components/seer/markdown/seerMarkdown.mdx @@ -10,6 +10,7 @@ import * as Storybook from 'sentry/stories'; import {BasicDemo, LinkifyDemo, StreamingEmbedExamples} from './__stories__/components'; import {AlertEmbedStory} from './__stories__/alertEmbedStory'; +import {ConversationEmbedStory} from './__stories__/conversationEmbedStory'; import {DashboardEmbedStory} from './__stories__/dashboardEmbedStory'; import {EmbedStory} from './__stories__/embedStory'; import {MonitorEmbedStory} from './__stories__/monitorEmbedStory'; @@ -137,6 +138,14 @@ Tag syntax: `{% name %}{"key":"value"}{% /name %}`. The JSON body is validated a +### conversation + + + +### conversationsQuery + + + ## Embed Architecture The embed system has four parts: diff --git a/static/app/views/explore/conversations/hooks/useConversation.tsx b/static/app/views/explore/conversations/hooks/useConversation.tsx index 9459f263d2df..a565adf0d13e 100644 --- a/static/app/views/explore/conversations/hooks/useConversation.tsx +++ b/static/app/views/explore/conversations/hooks/useConversation.tsx @@ -19,6 +19,13 @@ import type {TraceTree} from 'sentry/views/performance/newTraceDetails/traceMode export interface UseConversationsOptions { conversationId: string; endTimestamp?: number; + /** + * Projects to scope the span query to, overriding the page filters. A caller + * that is not the conversations route -- an embed rendered into some other + * page -- knows the conversation's own project and must not inherit whatever + * the host page happens to have selected. + */ + projects?: number[]; startTimestamp?: number; } @@ -314,8 +321,8 @@ export function useConversation( ? normalizeDateTimeParams(selection.datetime) : {}; - const project = - selection.projects.length > 0 ? selection.projects : [ALL_ACCESS_PROJECTS]; + const selectedProjects = conversation.projects ?? selection.projects; + const project = selectedProjects.length > 0 ? selectedProjects : [ALL_ACCESS_PROJECTS]; const queryParams = { project,