diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts index 720e165b7609d2..2866d3702eeea3 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.spec.ts @@ -1,6 +1,7 @@ import { detectAIContentType, parseXmlTagSegments, + preprocessInlineXmlTags, tryParsePythonDict, } from 'sentry/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection'; @@ -127,6 +128,41 @@ describe('tryParsePythonDict', () => { }); }); +describe('preprocessInlineXmlTags', () => { + it('replaces inline XML tags with italic markdown', () => { + const text = 'Before inner thought After'; + expect(preprocessInlineXmlTags(text)).toBe('Before *thinking: inner thought* After'); + }); + + it('leaves block-level tags at start of text untouched', () => { + const text = 'step 1 then more'; + expect(preprocessInlineXmlTags(text)).toBe('step 1 then more'); + }); + + it('leaves block-level tags after newline untouched', () => { + const text = 'Some text\ndeep thought'; + expect(preprocessInlineXmlTags(text)).toBe(text); + }); + + it('handles mixed inline and block tags', () => { + const text = + 'See the details here.\n\ndeep thought\n'; + expect(preprocessInlineXmlTags(text)).toBe( + 'See the *code: details* here.\n\ndeep thought\n' + ); + }); + + it('trims whitespace from inline tag content', () => { + const text = 'before spaced after'; + expect(preprocessInlineXmlTags(text)).toBe('before *tag: spaced* after'); + }); + + it('strips nested XML tags from inline tag content', () => { + const text = 'Text before nested after more'; + expect(preprocessInlineXmlTags(text)).toBe('Text *outer: before nested after* more'); + }); +}); + describe('parseXmlTagSegments', () => { it('splits text with XML tags into segments', () => { const text = 'Before inner thought After'; @@ -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', () => { @@ -168,7 +204,20 @@ describe('parseXmlTagSegments', () => { it('handles tags with hyphens in names', () => { const text = 'content'; - 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 = + '\nfile.ts\na bug\n'; + expect(parseXmlTagSegments(text)).toEqual([ + { + type: 'xml-tag', + tagName: 'bug_report', + content: '\nfile.ts\na bug\n', + }, + ]); }); }); diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.ts b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.ts index 9e1161b576672d..bb39264a545e9f 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.ts +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentDetection.ts @@ -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 | null { const trimmed = text.trim(); if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { @@ -50,10 +47,7 @@ export function tryParsePythonDict(text: string): Record | } } -/** - * Splits text into segments of plain text and XML-like tag blocks. - * Matches `...` 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; @@ -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(); + return `*${tagName}: ${stripped}*`; + }); +} + const XML_TAG_REGEX = /<([a-zA-Z][\w-]*)>[\s\S]*?<\/\1>/; const MARKDOWN_INDICATORS = [ @@ -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) { diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.spec.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.spec.tsx index 561f2389c8037d..82118cef34efb8 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.spec.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.spec.tsx @@ -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(); + expect(screen.getByText(/thinking: inner thought/)).toBeInTheDocument(); + }); + + it('renders block XML tags with styled wrappers', () => { + render(inner thought'} />); 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( ); - expect(screen.getByText('thinking')).toBeInTheDocument(); + expect(screen.getByText(/thinking: inner thought/)).toBeInTheDocument(); + }); + + it('renders nested XML tags recursively', () => { + const text = + '\nfile.ts\na bug\n'; + render(); + 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', () => { diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.tsx index 3b5e501a382ed3..b591d91e92760f 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiContentRenderer.tsx @@ -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; } @@ -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}`}} > {tagName} - - - + ); } function MarkdownWithXmlRenderer({text}: {text: string}) { - const segments = useMemo(() => parseXmlTagSegments(text), [text]); + const segments = useMemo( + () => parseXmlTagSegments(preprocessInlineXmlTags(text)), + [text] + ); return ( @@ -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,