Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
130 changes: 130 additions & 0 deletions src/sentry/seer/agent/embed_widgets.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
@@ -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}) => <div aria-label="Rendered markdown">{raw}</div>,
}));

function createConversation(conversation: Partial<Conversation>): 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(<ConversationEmbedStory />);

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(<ConversationEmbedStory />);

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(<ConversationEmbedStory />);

expect(
await screen.findByText('No conversation is available for this organization.')
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -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<Conversation[]>()(
'/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 (
<EmbedStory name="conversation">
{isPending ? (
<LoadingIndicator />
) : isError ? (
<Text variant="muted">Unable to load a conversation example.</Text>
) : conversation ? (
<EmbedVariant
name="conversation"
label="Conversation"
data={{
id: conversation.conversationId,
title: conversation.title ?? undefined,
projects:
conversation.projectId === null
? undefined
: [String(conversation.projectId)],
start: toIsoTimestamp(conversation.startTimestamp),
end: toIsoTimestamp(conversation.endTimestamp),
}}
/>
) : (
<Text variant="muted">No conversation is available for this organization.</Text>
)}
</EmbedStory>
);
}
Loading
Loading