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
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@ import {ClippedBox} from 'sentry/components/clippedBox';
import {EmptyMessage} from 'sentry/components/emptyMessage';
import {t} from 'sentry/locale';
import {getDuration} from 'sentry/utils/duration/getDuration';
import {MarkedText} from 'sentry/utils/marked/markedText';
import type {AITraceSpanNode} from 'sentry/views/insights/pages/agents/utils/types';
import {MessageToolCalls} from 'sentry/views/insights/pages/conversations/components/messageToolCalls';
import type {ConversationMessage} from 'sentry/views/insights/pages/conversations/utils/conversationMessages';
import {extractMessagesFromNodes} from 'sentry/views/insights/pages/conversations/utils/conversationMessages';
import {TraceDrawerComponents} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/styles';
import {AIContentRenderer} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer';

interface MessagesPanelProps {
nodes: AITraceSpanNode[];
Expand Down Expand Up @@ -117,10 +116,7 @@ export function MessagesPanel({nodes, selectedNodeId, onSelectNode}: MessagesPan
>
<Container padding="md">
<MessageText size="sm" align="left">
<MarkedText
as={TraceDrawerComponents.MarkdownContainer}
text={message.content}
/>
<AIContentRenderer text={message.content} inline />
</MessageText>
</Container>
</StyledClippedBox>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import {
detectAIContentType,
parseXmlTagSegments,
tryParsePythonDict,
} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection';

describe('detectAIContentType', () => {
it('detects valid JSON objects', () => {
const result = detectAIContentType('{"key": "value"}');
expect(result.type).toBe('json');
expect(result.parsedData).toEqual({key: 'value'});
});

it('detects valid JSON arrays', () => {
const result = detectAIContentType('[1, 2, 3]');
expect(result.type).toBe('json');
expect(result.parsedData).toEqual([1, 2, 3]);
});

it('does not detect JSON primitives as json type', () => {
expect(detectAIContentType('"just a string"').type).toBe('plain-text');
expect(detectAIContentType('42').type).toBe('plain-text');
expect(detectAIContentType('true').type).toBe('plain-text');
});

it('detects Python dicts with single-quoted keys', () => {
const result = detectAIContentType("{'key': 'value', 'flag': True}");
expect(result.type).toBe('python-dict');
expect(result.parsedData).toEqual({key: 'value', flag: true});
});

it('handles Python dicts with None and trailing commas', () => {
const result = detectAIContentType("{'a': None, 'b': False,}");
expect(result.type).toBe('python-dict');
expect(result.parsedData).toEqual({a: null, b: false});
});

it('detects partial/truncated JSON', () => {
const result = detectAIContentType('{"key": "value", "nested": {"inner": "trun');
expect(result.type).toBe('fixed-json');
expect(result.wasFixed).toBe(true);
expect(result.parsedData).not.toBeNull();
});

it('detects markdown with XML tags', () => {
const text =
'Here is my response\n<thinking>some internal thought</thinking>\nAnd more text';
const result = detectAIContentType(text);
expect(result.type).toBe('markdown-with-xml');
});

it('detects markdown syntax', () => {
expect(detectAIContentType('# Heading\nSome text').type).toBe('markdown');
expect(detectAIContentType('This has **bold** text').type).toBe('markdown');
expect(detectAIContentType('Use `code` here').type).toBe('markdown');
expect(detectAIContentType('[link](http://example.com)').type).toBe('markdown');
expect(detectAIContentType('> blockquote text').type).toBe('markdown');
expect(detectAIContentType('- list item').type).toBe('markdown');
expect(detectAIContentType('1. ordered item').type).toBe('markdown');
expect(detectAIContentType('```\ncode block\n```').type).toBe('markdown');
});

it('falls back to plain text', () => {
expect(detectAIContentType('Just some regular text here.').type).toBe('plain-text');
});

it('handles empty and whitespace strings', () => {
expect(detectAIContentType('').type).toBe('plain-text');
expect(detectAIContentType(' ').type).toBe('plain-text');
});

it('trims whitespace before detection', () => {
const result = detectAIContentType(' {"key": "value"} ');
expect(result.type).toBe('json');
});

it('prefers JSON over Python dict when valid JSON', () => {
const result = detectAIContentType('{"key": "value"}');
expect(result.type).toBe('json');
});

it('falls through when parseJsonWithFix returns null for [Filtered]', () => {
const result = detectAIContentType('[Filtered]');
expect(result.type).toBe('plain-text');
});
});

describe('tryParsePythonDict', () => {
it('converts single-quoted keys to JSON', () => {
const result = tryParsePythonDict("{'name': 'test'}");
expect(result).toEqual({name: 'test'});
});

it('converts Python booleans and None', () => {
const result = tryParsePythonDict("{'a': True, 'b': False, 'c': None}");
expect(result).toEqual({a: true, b: false, c: null});
});

it('handles trailing commas', () => {
const result = tryParsePythonDict("{'x': 1, 'y': 2,}");
expect(result).toEqual({x: 1, y: 2});
});

it('returns null for non-dict text', () => {
expect(tryParsePythonDict('hello world')).toBeNull();
});

it('returns null for text without single-quoted keys', () => {
expect(tryParsePythonDict('{"key": "value"}')).toBeNull();
});

it('returns null for unconvertible text', () => {
expect(tryParsePythonDict('{key: value}')).toBeNull();
});

it('returns null when mixed quotes produce invalid JSON', () => {
expect(tryParsePythonDict("{'key': 'text with \"inner\" quotes'}")).toBeNull();
});

it('handles Python dicts where values contain markdown', () => {
const result = tryParsePythonDict(
"{'content': 'Given a query, you should **determine** if the passage is relevant'}"
);
expect(result).toEqual({
content: 'Given a query, you should **determine** if the passage is relevant',
});
});
});

describe('parseXmlTagSegments', () => {
it('splits text with XML tags into segments', () => {
const text = 'Before <thinking>inner thought</thinking> After';
const segments = parseXmlTagSegments(text);
expect(segments).toEqual([
{type: 'text', content: 'Before '},
{type: 'xml-tag', tagName: 'thinking', content: 'inner thought'},
{type: 'text', content: ' After'},
]);
});

it('handles multiple XML tags', () => {
const text = '<plan>step 1</plan> then <result>done</result>';
const segments = parseXmlTagSegments(text);
expect(segments).toEqual([
{type: 'xml-tag', tagName: 'plan', content: 'step 1'},
{type: 'text', content: ' then '},
{type: 'xml-tag', tagName: 'result', content: 'done'},
]);
});

it('handles multiline content inside tags', () => {
const text = '<thinking>\nline1\nline2\n</thinking>';
const segments = parseXmlTagSegments(text);
expect(segments).toEqual([
{type: 'xml-tag', tagName: 'thinking', content: '\nline1\nline2\n'},
]);
});

it('returns single text segment when no XML tags', () => {
const text = 'just plain text';
const segments = parseXmlTagSegments(text);
expect(segments).toEqual([{type: 'text', content: 'just plain text'}]);
});

it('handles empty string', () => {
expect(parseXmlTagSegments('')).toEqual([]);
});

it('handles tags with hyphens in names', () => {
const text = '<my-tag>content</my-tag>';
const segments = parseXmlTagSegments(text);
expect(segments).toEqual([{type: 'xml-tag', tagName: 'my-tag', content: 'content'}]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import {parseJsonWithFix} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/utils';

type AIContentType =
| 'json'
| 'fixed-json'
| 'python-dict'
| 'markdown-with-xml'
| 'markdown'
| 'plain-text';

type ContentSegment =
| {content: string; type: 'text'}
| {content: string; tagName: string; type: 'xml-tag'};

interface AIContentDetectionResult {
type: AIContentType;
parsedData?: unknown;
wasFixed?: boolean;
}

/**
* Best-effort conversion of a Python dict literal to a JSON-parseable string.
* Returns the parsed object on success, or null if conversion fails.
*/
export function tryParsePythonDict(text: string): Record<PropertyKey, unknown> | null {
const trimmed = text.trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return null;
}

if (!/'.+?'\s*:/.test(trimmed)) {
return null;
}

try {
let converted = trimmed;
converted = converted.replace(/\bTrue\b/g, 'true');
converted = converted.replace(/\bFalse\b/g, 'false');
converted = converted.replace(/\bNone\b/g, 'null');
converted = converted.replace(/'/g, '"');
converted = converted.replace(/,\s*([}\]])/g, '$1');

const parsed = JSON.parse(converted);
if (typeof parsed === 'object' && parsed !== null) {
return parsed;
}
return null;
} catch {
return null;
}
}

/**
* Splits text into segments of plain text and XML-like tag blocks.
* Matches `<tagname>...</tagname>` patterns (including multiline content).
*/
export function parseXmlTagSegments(text: string): ContentSegment[] {
const segments: ContentSegment[] = [];
const xmlTagRegex = /<([a-zA-Z][\w-]*?)>([\s\S]*?)<\/\1>/g;
let lastIndex = 0;

for (const match of text.matchAll(xmlTagRegex)) {
if (match.index > lastIndex) {
segments.push({type: 'text', content: text.slice(lastIndex, match.index)});
}
segments.push({
type: 'xml-tag',
tagName: match[1]!,
content: match[2]!,
});
lastIndex = match.index + match[0].length;
}

if (lastIndex < text.length) {
segments.push({type: 'text', content: text.slice(lastIndex)});
}

return segments;
}

const XML_TAG_REGEX = /<([a-zA-Z][\w-]*?)>[\s\S]*?<\/\1>/;

const MARKDOWN_INDICATORS = [
/^#{1,6}\s/m, // headings
/\*\*.+?\*\*/, // bold
/`.+?`/, // inline code
/\[.+?\]\(.+?\)/, // links
/^>\s/m, // blockquotes
/^[-*]\s/m, // unordered lists
/^\d+\.\s/m, // ordered lists
/^```/m, // code fences
];

/**
* Detects the content type of an AI response string.
* Short-circuits on first match in priority order:
* 1. Valid JSON (object/array)
* 2. Python dict
* 3. Partial/truncated JSON (fixable)
* 4. Markdown with XML tags
* 5. Markdown
* 6. Plain text
*/
export function detectAIContentType(text: string): AIContentDetectionResult {
const trimmed = text.trim();
if (!trimmed) {
return {type: 'plain-text'};
}

try {
const parsed = JSON.parse(trimmed);
if (typeof parsed === 'object' && parsed !== null) {
return {type: 'json', parsedData: parsed};
}
} catch {
// noop
}

if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
const pythonResult = tryParsePythonDict(trimmed);
if (pythonResult) {
return {type: 'python-dict', parsedData: pythonResult};
}

const {parsed, fixedInvalidJson} = parseJsonWithFix(trimmed);
if (fixedInvalidJson && parsed !== null && typeof parsed === 'object') {
return {type: 'fixed-json', parsedData: parsed, wasFixed: true};
}
}

if (XML_TAG_REGEX.test(trimmed)) {
return {type: 'markdown-with-xml'};
}

if (MARKDOWN_INDICATORS.some(re => re.test(trimmed))) {
return {type: 'markdown'};
}

return {type: 'plain-text'};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {render, screen} from 'sentry-test/reactTestingLibrary';

import {AIContentRenderer} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer';

describe('AIContentRenderer', () => {
it('renders plain text inline', () => {
render(<AIContentRenderer text="Hello world" inline />);
expect(screen.getByText('Hello world')).toBeInTheDocument();
});

it('renders markdown content inline', () => {
render(<AIContentRenderer text="**bold text**" inline />);
expect(screen.getByText('bold text')).toBeInTheDocument();
});

it('renders JSON as structured data', () => {
render(<AIContentRenderer text='{"key": "value"}' />);
expect(screen.getByText('key')).toBeInTheDocument();
});

it('renders fixed JSON with truncated indicator', () => {
render(<AIContentRenderer text='{"key": "value", "nested": {"inner": "trun' />);
expect(screen.getByText('Truncated')).toBeInTheDocument();
});

it('renders Python dict as JSON', () => {
render(<AIContentRenderer text="{'name': 'test', 'flag': True}" />);
expect(screen.getByText('name')).toBeInTheDocument();
});

it('renders XML tags with styled wrappers', () => {
render(<AIContentRenderer text="Before <thinking>inner thought</thinking> After" />);
expect(screen.getByText('thinking')).toBeInTheDocument();
});

it('renders XML tags with styled wrappers when inline', () => {
render(
<AIContentRenderer text="Before <thinking>inner thought</thinking> After" inline />
);
expect(screen.getByText('thinking')).toBeInTheDocument();
});

it('wraps plain text in MultilineText by default', () => {
render(<AIContentRenderer text="simple text" />);
expect(screen.getByText('simple text')).toBeInTheDocument();
});
});
Loading
Loading