Skip to content

Commit 66e8de7

Browse files
obostjancicgeorge-sentry
authored andcommitted
fix(ai-conversations): Improve XML tag rendering in AI span details (#112346)
1 parent 36405ad commit 66e8de7

File tree

4 files changed

+97
-36
lines changed

4 files changed

+97
-36
lines changed

static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
detectAIContentType,
33
parseXmlTagSegments,
4+
preprocessInlineXmlTags,
45
tryParsePythonDict,
56
} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection';
67

@@ -127,6 +128,41 @@ describe('tryParsePythonDict', () => {
127128
});
128129
});
129130

131+
describe('preprocessInlineXmlTags', () => {
132+
it('replaces inline XML tags with italic markdown', () => {
133+
const text = 'Before <thinking>inner thought</thinking> After';
134+
expect(preprocessInlineXmlTags(text)).toBe('Before *thinking: inner thought* After');
135+
});
136+
137+
it('leaves block-level tags at start of text untouched', () => {
138+
const text = '<plan>step 1</plan> then more';
139+
expect(preprocessInlineXmlTags(text)).toBe('<plan>step 1</plan> then more');
140+
});
141+
142+
it('leaves block-level tags after newline untouched', () => {
143+
const text = 'Some text\n<thinking>deep thought</thinking>';
144+
expect(preprocessInlineXmlTags(text)).toBe(text);
145+
});
146+
147+
it('handles mixed inline and block tags', () => {
148+
const text =
149+
'See the <code>details</code> here.\n<thinking>\ndeep thought\n</thinking>';
150+
expect(preprocessInlineXmlTags(text)).toBe(
151+
'See the *code: details* here.\n<thinking>\ndeep thought\n</thinking>'
152+
);
153+
});
154+
155+
it('trims whitespace from inline tag content', () => {
156+
const text = 'before <tag> spaced </tag> after';
157+
expect(preprocessInlineXmlTags(text)).toBe('before *tag: spaced* after');
158+
});
159+
160+
it('strips nested XML tags from inline tag content', () => {
161+
const text = 'Text <outer>before <inner>nested</inner> after</outer> more';
162+
expect(preprocessInlineXmlTags(text)).toBe('Text *outer: before nested after* more');
163+
});
164+
});
165+
130166
describe('parseXmlTagSegments', () => {
131167
it('splits text with XML tags into segments', () => {
132168
const text = 'Before <thinking>inner thought</thinking> After';
@@ -157,9 +193,9 @@ describe('parseXmlTagSegments', () => {
157193
});
158194

159195
it('returns single text segment when no XML tags', () => {
160-
const text = 'just plain text';
161-
const segments = parseXmlTagSegments(text);
162-
expect(segments).toEqual([{type: 'text', content: 'just plain text'}]);
196+
expect(parseXmlTagSegments('just plain text')).toEqual([
197+
{type: 'text', content: 'just plain text'},
198+
]);
163199
});
164200

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

169205
it('handles tags with hyphens in names', () => {
170206
const text = '<my-tag>content</my-tag>';
171-
const segments = parseXmlTagSegments(text);
172-
expect(segments).toEqual([{type: 'xml-tag', tagName: 'my-tag', content: 'content'}]);
207+
expect(parseXmlTagSegments(text)).toEqual([
208+
{type: 'xml-tag', tagName: 'my-tag', content: 'content'},
209+
]);
210+
});
211+
212+
it('extracts outer tag with nested tags preserved in content', () => {
213+
const text =
214+
'<bug_report>\n<location>file.ts</location>\n<description>a bug</description>\n</bug_report>';
215+
expect(parseXmlTagSegments(text)).toEqual([
216+
{
217+
type: 'xml-tag',
218+
tagName: 'bug_report',
219+
content: '\n<location>file.ts</location>\n<description>a bug</description>\n',
220+
},
221+
]);
173222
});
174223
});

static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.ts

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,7 @@ interface AIContentDetectionResult {
1818
wasFixed?: boolean;
1919
}
2020

21-
/**
22-
* Best-effort conversion of a Python dict literal to a JSON-parseable string.
23-
* Returns the parsed object on success, or null if conversion fails.
24-
*/
21+
/** Best-effort conversion of a Python dict literal to a JSON-parseable string. */
2522
export function tryParsePythonDict(text: string): Record<PropertyKey, unknown> | null {
2623
const trimmed = text.trim();
2724
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
@@ -50,10 +47,7 @@ export function tryParsePythonDict(text: string): Record<PropertyKey, unknown> |
5047
}
5148
}
5249

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

75+
/** Replaces inline XML tags with italic markdown, leaves block-level tags untouched. */
76+
export function preprocessInlineXmlTags(text: string): string {
77+
const xmlTagRegex = /<([a-zA-Z][\w-]*)>([\s\S]*?)<\/\1>/g;
78+
return text.replace(xmlTagRegex, (match, tagName, content, offset) => {
79+
const isBlock = offset === 0 || /\n\s*$/.test(text.slice(0, offset));
80+
if (isBlock) {
81+
return match;
82+
}
83+
const stripped = content.replace(/<\/?[a-zA-Z][\w-]*>/g, '').trim();
84+
return `*${tagName}: ${stripped}*`;
85+
});
86+
}
87+
8188
const XML_TAG_REGEX = /<([a-zA-Z][\w-]*)>[\s\S]*?<\/\1>/;
8289

8390
const MARKDOWN_INDICATORS = [
@@ -91,16 +98,7 @@ const MARKDOWN_INDICATORS = [
9198
/^```/m, // code fences
9299
];
93100

94-
/**
95-
* Detects the content type of an AI response string.
96-
* Short-circuits on first match in priority order:
97-
* 1. Valid JSON (object/array)
98-
* 2. Python dict
99-
* 3. Partial/truncated JSON (fixable)
100-
* 4. Markdown with XML tags
101-
* 5. Markdown
102-
* 6. Plain text
103-
*/
101+
/** Detects AI content type: JSON, Python dict, markdown-with-xml, markdown, or plain text. */
104102
export function detectAIContentType(text: string): AIContentDetectionResult {
105103
const trimmed = text.trim();
106104
if (!trimmed) {

static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.spec.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,32 @@ describe('AIContentRenderer', () => {
2828
expect(screen.getByText('name')).toBeInTheDocument();
2929
});
3030

31-
it('renders XML tags with styled wrappers', () => {
31+
it('renders inline XML tags as italic text within the flow', () => {
3232
render(<AIContentRenderer text="Before <thinking>inner thought</thinking> After" />);
33+
expect(screen.getByText(/thinking: inner thought/)).toBeInTheDocument();
34+
});
35+
36+
it('renders block XML tags with styled wrappers', () => {
37+
render(<AIContentRenderer text={'Text\n<thinking>inner thought</thinking>'} />);
3338
expect(screen.getByText('thinking')).toBeInTheDocument();
3439
});
3540

36-
it('renders XML tags with styled wrappers when inline', () => {
41+
it('renders inline XML tags as italic text when inline', () => {
3742
render(
3843
<AIContentRenderer text="Before <thinking>inner thought</thinking> After" inline />
3944
);
40-
expect(screen.getByText('thinking')).toBeInTheDocument();
45+
expect(screen.getByText(/thinking: inner thought/)).toBeInTheDocument();
46+
});
47+
48+
it('renders nested XML tags recursively', () => {
49+
const text =
50+
'<bug_report>\n<location>file.ts</location>\n<description>a bug</description>\n</bug_report>';
51+
render(<AIContentRenderer text={text} />);
52+
expect(screen.getByText('bug_report')).toBeInTheDocument();
53+
expect(screen.getByText('location')).toBeInTheDocument();
54+
expect(screen.getByText('description')).toBeInTheDocument();
55+
expect(screen.getByText('file.ts')).toBeInTheDocument();
56+
expect(screen.getByText('a bug')).toBeInTheDocument();
4157
});
4258

4359
it('wraps plain text in MultilineText by default', () => {

static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import {MarkedText} from 'sentry/utils/marked/markedText';
99
import {
1010
detectAIContentType,
1111
parseXmlTagSegments,
12+
preprocessInlineXmlTags,
1213
} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection';
1314
import {TraceDrawerComponents} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/styles';
1415

1516
interface AIContentRendererProps {
1617
text: string;
17-
/** When true, renders content directly without a wrapper or raw/pretty toggle. */
1818
inline?: boolean;
1919
maxJsonDepth?: number;
2020
}
@@ -27,22 +27,23 @@ function XmlTagBlock({tagName, content}: {content: string; tagName: string}) {
2727
direction="column"
2828
padding="0 0 0 md"
2929
margin="sm 0"
30-
style={{borderLeft: `3px solid ${theme.tokens.border.accent.moderate}`}}
30+
style={{borderLeft: `2px solid ${theme.tokens.border.primary}`}}
3131
>
3232
<Container margin="0 0 xs 0">
3333
<Text size="xs" variant="muted">
3434
{tagName}
3535
</Text>
3636
</Container>
37-
<Text italic>
38-
<MarkedText as={TraceDrawerComponents.MarkdownContainer} text={content} />
39-
</Text>
37+
<MarkdownWithXmlRenderer text={content} />
4038
</Flex>
4139
);
4240
}
4341

4442
function MarkdownWithXmlRenderer({text}: {text: string}) {
45-
const segments = useMemo(() => parseXmlTagSegments(text), [text]);
43+
const segments = useMemo(
44+
() => parseXmlTagSegments(preprocessInlineXmlTags(text)),
45+
[text]
46+
);
4647

4748
return (
4849
<Fragment>
@@ -61,10 +62,7 @@ function MarkdownWithXmlRenderer({text}: {text: string}) {
6162
);
6263
}
6364

64-
/**
65-
* Unified AI content renderer that auto-detects content type and renders appropriately.
66-
* Handles JSON, Python dicts, partial JSON, markdown with XML tags, markdown, and plain text.
67-
*/
65+
/** Auto-detects AI content type and renders appropriately. */
6866
export function AIContentRenderer({
6967
text,
7068
inline = false,

0 commit comments

Comments
 (0)