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
@@ -1,6 +1,7 @@
import {
detectAIContentType,
parseXmlTagSegments,
preprocessInlineXmlTags,
tryParsePythonDict,
} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection';

Expand Down Expand Up @@ -127,6 +128,41 @@ describe('tryParsePythonDict', () => {
});
});

describe('preprocessInlineXmlTags', () => {
it('replaces inline XML tags with italic markdown', () => {
const text = 'Before <thinking>inner thought</thinking> After';
expect(preprocessInlineXmlTags(text)).toBe('Before *thinking: inner thought* After');
});

it('leaves block-level tags at start of text untouched', () => {
const text = '<plan>step 1</plan> then more';
expect(preprocessInlineXmlTags(text)).toBe('<plan>step 1</plan> then more');
});

it('leaves block-level tags after newline untouched', () => {
const text = 'Some text\n<thinking>deep thought</thinking>';
expect(preprocessInlineXmlTags(text)).toBe(text);
});

it('handles mixed inline and block tags', () => {
const text =
'See the <code>details</code> here.\n<thinking>\ndeep thought\n</thinking>';
expect(preprocessInlineXmlTags(text)).toBe(
'See the *code: details* here.\n<thinking>\ndeep thought\n</thinking>'
);
});

it('trims whitespace from inline tag content', () => {
const text = 'before <tag> spaced </tag> after';
expect(preprocessInlineXmlTags(text)).toBe('before *tag: spaced* after');
});

it('strips nested XML tags from inline tag content', () => {
const text = 'Text <outer>before <inner>nested</inner> after</outer> more';
expect(preprocessInlineXmlTags(text)).toBe('Text *outer: before nested after* more');
});
});

describe('parseXmlTagSegments', () => {
it('splits text with XML tags into segments', () => {
const text = 'Before <thinking>inner thought</thinking> After';
Expand Down Expand Up @@ -157,9 +193,9 @@ describe('parseXmlTagSegments', () => {
});

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'}]);
expect(parseXmlTagSegments('just plain text')).toEqual([
{type: 'text', content: 'just plain text'},
]);
});

it('handles empty string', () => {
Expand All @@ -168,7 +204,20 @@ describe('parseXmlTagSegments', () => {

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'}]);
expect(parseXmlTagSegments(text)).toEqual([
{type: 'xml-tag', tagName: 'my-tag', content: 'content'},
]);
});

it('extracts outer tag with nested tags preserved in content', () => {
const text =
'<bug_report>\n<location>file.ts</location>\n<description>a bug</description>\n</bug_report>';
expect(parseXmlTagSegments(text)).toEqual([
{
type: 'xml-tag',
tagName: 'bug_report',
content: '\n<location>file.ts</location>\n<description>a bug</description>\n',
},
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,7 @@ interface AIContentDetectionResult {
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.
*/
/** Best-effort conversion of a Python dict literal to a JSON-parseable string. */
export function tryParsePythonDict(text: string): Record<PropertyKey, unknown> | null {
const trimmed = text.trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
Expand Down Expand Up @@ -50,10 +47,7 @@ export function tryParsePythonDict(text: string): Record<PropertyKey, unknown> |
}
}

/**
* Splits text into segments of plain text and XML-like tag blocks.
* Matches `<tagname>...</tagname>` patterns (including multiline content).
*/
/** Splits text into segments of plain text and XML-like tag blocks. */
export function parseXmlTagSegments(text: string): ContentSegment[] {
const segments: ContentSegment[] = [];
const xmlTagRegex = /<([a-zA-Z][\w-]*)>([\s\S]*?)<\/\1>/g;
Expand All @@ -78,6 +72,19 @@ export function parseXmlTagSegments(text: string): ContentSegment[] {
return segments;
}

/** Replaces inline XML tags with italic markdown, leaves block-level tags untouched. */
export function preprocessInlineXmlTags(text: string): string {
const xmlTagRegex = /<([a-zA-Z][\w-]*)>([\s\S]*?)<\/\1>/g;
return text.replace(xmlTagRegex, (match, tagName, content, offset) => {
const isBlock = offset === 0 || /\n\s*$/.test(text.slice(0, offset));
if (isBlock) {
return match;
}
const stripped = content.replace(/<\/?[a-zA-Z][\w-]*>/g, '').trim();
Comment thread
obostjancic marked this conversation as resolved.
Dismissed
return `*${tagName}: ${stripped}*`;
Comment thread
obostjancic marked this conversation as resolved.
});
}
Comment thread
cursor[bot] marked this conversation as resolved.

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

const MARKDOWN_INDICATORS = [
Expand All @@ -91,16 +98,7 @@ const MARKDOWN_INDICATORS = [
/^```/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
*/
/** Detects AI content type: JSON, Python dict, markdown-with-xml, markdown, or plain text. */
export function detectAIContentType(text: string): AIContentDetectionResult {
const trimmed = text.trim();
if (!trimmed) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,32 @@ describe('AIContentRenderer', () => {
expect(screen.getByText('name')).toBeInTheDocument();
});

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

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

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

it('renders nested XML tags recursively', () => {
const text =
'<bug_report>\n<location>file.ts</location>\n<description>a bug</description>\n</bug_report>';
render(<AIContentRenderer text={text} />);
expect(screen.getByText('bug_report')).toBeInTheDocument();
expect(screen.getByText('location')).toBeInTheDocument();
expect(screen.getByText('description')).toBeInTheDocument();
expect(screen.getByText('file.ts')).toBeInTheDocument();
expect(screen.getByText('a bug')).toBeInTheDocument();
});

it('wraps plain text in MultilineText by default', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ import {MarkedText} from 'sentry/utils/marked/markedText';
import {
detectAIContentType,
parseXmlTagSegments,
preprocessInlineXmlTags,
} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection';
import {TraceDrawerComponents} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/styles';

interface AIContentRendererProps {
text: string;
/** When true, renders content directly without a wrapper or raw/pretty toggle. */
inline?: boolean;
maxJsonDepth?: number;
}
Expand All @@ -27,22 +27,23 @@ function XmlTagBlock({tagName, content}: {content: string; tagName: string}) {
direction="column"
padding="0 0 0 md"
margin="sm 0"
style={{borderLeft: `3px solid ${theme.tokens.border.accent.moderate}`}}
style={{borderLeft: `2px solid ${theme.tokens.border.primary}`}}
>
<Container margin="0 0 xs 0">
<Text size="xs" variant="muted">
{tagName}
</Text>
</Container>
<Text italic>
<MarkedText as={TraceDrawerComponents.MarkdownContainer} text={content} />
</Text>
<MarkdownWithXmlRenderer text={content} />
</Flex>
);
}

function MarkdownWithXmlRenderer({text}: {text: string}) {
const segments = useMemo(() => parseXmlTagSegments(text), [text]);
const segments = useMemo(
() => parseXmlTagSegments(preprocessInlineXmlTags(text)),
[text]
);

return (
<Fragment>
Expand All @@ -61,10 +62,7 @@ function MarkdownWithXmlRenderer({text}: {text: string}) {
);
}

/**
* Unified AI content renderer that auto-detects content type and renders appropriately.
* Handles JSON, Python dicts, partial JSON, markdown with XML tags, markdown, and plain text.
*/
/** Auto-detects AI content type and renders appropriately. */
export function AIContentRenderer({
text,
inline = false,
Expand Down
Loading