Skip to content

Commit e2ecc0b

Browse files
authored
Fix/agentflow Add support for XML tag in markdown input and render markdown conditionally (#6145)
* Changes to limit markdown * Remove extra content * Test changes * Add support to escape XML tags * FIx lint errors * Revert changes to server file * Fix Gemini comments * Fix gemini comments * Refactor to remove duplicated logic
1 parent 833f911 commit e2ecc0b

File tree

11 files changed

+896
-186
lines changed

11 files changed

+896
-186
lines changed

packages/agentflow/src/__mocks__/@tiptap/react.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { createElement, forwardRef } from 'react'
22

3+
/** Extract all text from a ProseMirror JSON node recursively. */
4+
function extractPmText(node: { text?: string; content?: unknown[] }): string {
5+
if (node.text) return node.text
6+
if (node.content) return (node.content as { text?: string; content?: unknown[] }[]).map(extractPmText).join('')
7+
return ''
8+
}
9+
310
export const useEditor = (config?: Record<string, unknown>) => {
411
// Track the current content so getMarkdown/getHTML reflect setContent() calls,
512
// mirroring real TipTap behaviour where setContent updates the editor state.
@@ -9,12 +16,22 @@ export const useEditor = (config?: Record<string, unknown>) => {
916
getMarkdown: () => currentContent,
1017
isEmpty: !currentContent,
1118
setEditable: jest.fn(),
19+
// Returns a minimal ProseMirror JSON for the current string content.
20+
// Allows unescapeXmlEntities() to walk and mutate the text node.
21+
getJSON: () => ({
22+
type: 'doc',
23+
content: [{ type: 'paragraph', content: [{ type: 'text', text: currentContent }] }]
24+
}),
1225
commands: {
1326
focus: jest.fn(),
14-
// Capture the first argument (the content string) so getMarkdown/getHTML
15-
// return the value that was last loaded into the editor.
16-
setContent: jest.fn((content: string) => {
17-
currentContent = content
27+
// Accepts both string content and ProseMirror JSON objects (second setContent call
28+
// in the two-step XML escape/unescape load sequence).
29+
setContent: jest.fn((content: string | { text?: string; content?: unknown[] }) => {
30+
if (typeof content === 'string') {
31+
currentContent = content
32+
} else if (content && typeof content === 'object') {
33+
currentContent = extractPmText(content)
34+
}
1835
})
1936
},
2037
_onUpdate: config?.onUpdate

packages/agentflow/src/atoms/ExpandTextDialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Box, Button, Dialog, DialogActions, DialogContent, TextField, ToggleBut
44
import { IconCode, IconPencil } from '@tabler/icons-react'
55
import type { Editor } from '@tiptap/react'
66

7-
import { getEditorMarkdown } from '@/atoms/utils/'
7+
import { getEditorMarkdown, unescapeXmlTags } from '@/atoms/utils/'
88

99
import { CodeInput } from './CodeInput'
1010
import { RichTextEditor } from './RichTextEditor.lazy'
@@ -73,7 +73,7 @@ export function ExpandTextDialog({
7373
// When switching to Source, flush the editor's current state to markdown so the
7474
// textarea shows markdown rather than a raw HTML string (Gap 3 fix — mirrors PR #6021).
7575
if (newMode === 'source' && editorRef.current) {
76-
setLocalValue(getEditorMarkdown(editorRef.current))
76+
setLocalValue(unescapeXmlTags(getEditorMarkdown(editorRef.current)))
7777
}
7878
setMode(newMode)
7979
}

packages/agentflow/src/atoms/RichTextEditor.test.tsx

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
import { render, screen } from '@testing-library/react'
2+
import { Markdown } from '@tiptap/markdown'
3+
import StarterKit from '@tiptap/starter-kit'
24

35
import { RichTextEditor } from './RichTextEditor'
46

57
// --- Mock TipTap ---
68
// editor.getMarkdown() is added directly on the Editor interface by @tiptap/markdown
79
// via module augmentation — it is NOT nested under storage.markdown.
810
let capturedOnUpdate: ((args: { editor: { getMarkdown: () => string } }) => void) | undefined
11+
let capturedExtensions: unknown[] | undefined
912

1013
jest.mock('@tiptap/react', () => ({
1114
useEditor: (config: Record<string, unknown>) => {
1215
// Capture the onUpdate callback so tests can simulate edits
1316
capturedOnUpdate = config.onUpdate as typeof capturedOnUpdate
17+
capturedExtensions = config.extensions as unknown[]
1418
return {
1519
setEditable: jest.fn(),
1620
commands: { focus: jest.fn(), setContent: jest.fn() },
17-
getMarkdown: () => 'mock markdown'
21+
getMarkdown: () => 'mock markdown',
22+
getJSON: () => ({ type: 'doc', content: [] })
1823
}
1924
},
2025
EditorContent: ({ editor, ...rest }: { editor: unknown; [key: string]: unknown }) => (
@@ -49,14 +54,16 @@ jest.mock('lowlight', () => ({
4954
createLowlight: jest.fn(() => ({ register: jest.fn() }))
5055
}))
5156

52-
const mockOnChange = jest.fn()
57+
describe('RichTextEditor', () => {
58+
let mockOnChange: jest.Mock
5359

54-
beforeEach(() => {
55-
jest.clearAllMocks()
56-
capturedOnUpdate = undefined
57-
})
60+
beforeEach(() => {
61+
jest.clearAllMocks()
62+
capturedOnUpdate = undefined
63+
capturedExtensions = undefined
64+
mockOnChange = jest.fn()
65+
})
5866

59-
describe('RichTextEditor', () => {
6067
it('should render the editor container', () => {
6168
render(<RichTextEditor value='Hello' onChange={mockOnChange} />)
6269

@@ -100,4 +107,70 @@ describe('RichTextEditor', () => {
100107

101108
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
102109
})
110+
111+
describe('buildExtensions — useMarkdown flag', () => {
112+
it('includes Markdown extension when useMarkdown is true (default)', () => {
113+
render(<RichTextEditor value='' onChange={mockOnChange} useMarkdown={true} />)
114+
115+
expect(capturedExtensions).toContain(Markdown)
116+
})
117+
118+
it('excludes Markdown extension when useMarkdown is false', () => {
119+
render(<RichTextEditor value='' onChange={mockOnChange} useMarkdown={false} />)
120+
121+
expect(capturedExtensions).not.toContain(Markdown)
122+
})
123+
124+
it('passes link:false to StarterKit when useMarkdown is false', () => {
125+
render(<RichTextEditor value='' onChange={mockOnChange} useMarkdown={false} />)
126+
127+
expect(StarterKit.configure).toHaveBeenCalledWith(expect.objectContaining({ link: false }))
128+
})
129+
130+
it('does not pass link:false to StarterKit when useMarkdown is true (default)', () => {
131+
render(<RichTextEditor value='' onChange={mockOnChange} useMarkdown={true} />)
132+
133+
expect(StarterKit.configure).not.toHaveBeenCalledWith(expect.objectContaining({ link: false }))
134+
})
135+
})
136+
137+
describe('XML tag preservation in onUpdate', () => {
138+
it('should unescape entity-escaped XML tags before calling onChange', () => {
139+
render(<RichTextEditor value='' onChange={mockOnChange} />)
140+
141+
// Simulate getMarkdown() returning entity-escaped tags (safety-net path)
142+
capturedOnUpdate!({
143+
editor: { getMarkdown: () => '&lt;instructions&gt;Be helpful&lt;/instructions&gt;' }
144+
})
145+
146+
expect(mockOnChange).toHaveBeenCalledWith('<instructions>Be helpful</instructions>')
147+
})
148+
149+
it('should pass through raw XML tags in markdown unchanged', () => {
150+
render(<RichTextEditor value='' onChange={mockOnChange} />)
151+
152+
capturedOnUpdate!({
153+
editor: { getMarkdown: () => '<instructions>Be helpful</instructions>' }
154+
})
155+
156+
expect(mockOnChange).toHaveBeenCalledWith('<instructions>Be helpful</instructions>')
157+
})
158+
159+
it('should preserve XML tags mixed with markdown', () => {
160+
render(<RichTextEditor value='' onChange={mockOnChange} />)
161+
162+
capturedOnUpdate!({
163+
editor: { getMarkdown: () => '# Title\n&lt;question&gt;{{input}}&lt;/question&gt;\n**bold**' }
164+
})
165+
166+
expect(mockOnChange).toHaveBeenCalledWith('# Title\n<question>{{input}}</question>\n**bold**')
167+
})
168+
169+
it('should render without error when useMarkdown is false', () => {
170+
// HTML mode is exercised by other tests; this guards the component mounts cleanly.
171+
render(<RichTextEditor value='' onChange={mockOnChange} useMarkdown={false} />)
172+
173+
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
174+
})
175+
})
103176
})

packages/agentflow/src/atoms/RichTextEditor.tsx

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import python from 'highlight.js/lib/languages/python'
1616
import typescript from 'highlight.js/lib/languages/typescript'
1717
import { createLowlight } from 'lowlight'
1818

19-
import { getEditorMarkdown, isHtmlContent } from '@/atoms/utils/'
19+
import { escapeXmlTags, getEditorMarkdown, isHtmlContent, unescapeXmlEntities, unescapeXmlTags } from '@/atoms/utils/'
2020
import { tokens } from '@/core/theme/tokens'
2121

2222
const lowlight = createLowlight()
@@ -42,11 +42,22 @@ export interface RichTextEditorProps {
4242
useMarkdown?: boolean
4343
}
4444

45+
/* ── Helpers ── */
46+
47+
function loadContent(editor: Editor, value: string, markdown: boolean) {
48+
if (!markdown || isHtmlContent(value)) {
49+
editor.commands.setContent(value, { emitUpdate: false, contentType: 'html' })
50+
} else {
51+
editor.commands.setContent(escapeXmlTags(value), { emitUpdate: false, contentType: 'markdown' })
52+
editor.commands.setContent(unescapeXmlEntities(editor.getJSON()), { emitUpdate: false })
53+
}
54+
}
55+
4556
/* ── TipTap extensions (no mention/variable support — that belongs in features/) ── */
4657

47-
const buildExtensions = (placeholder?: string) => [
48-
Markdown,
49-
StarterKit.configure({ codeBlock: false }),
58+
const buildExtensions = (placeholder?: string, useMarkdown = true) => [
59+
...(useMarkdown ? [Markdown] : []),
60+
StarterKit.configure({ codeBlock: false, ...(!useMarkdown && { link: false }) }),
5061
CodeBlockLowlight.configure({ lowlight, enableTabIndentation: true, tabSize: 2 }),
5162
...(placeholder ? [Placeholder.configure({ placeholder })] : [])
5263
]
@@ -174,17 +185,18 @@ export function RichTextEditor({
174185
const initialValueRef = useRef(value)
175186
const useMarkdownRef = useRef(useMarkdown)
176187

177-
const extensions = useMemo(() => buildExtensions(placeholder), [placeholder])
188+
const extensions = useMemo(() => buildExtensions(placeholder, useMarkdown), [placeholder, useMarkdown])
178189

179190
const editor = useEditor({
180191
extensions,
181192
content: '',
182193
editable: !disabled,
183194
autofocus: autoFocus ? 'end' : false,
184195
onUpdate: ({ editor: ed }) => {
185-
const value = useMarkdown ? getEditorMarkdown(ed) : ed.getHTML()
186-
lastEmittedRef.current = value
187-
onChangeRef.current(value)
196+
const raw = useMarkdown ? getEditorMarkdown(ed) : ed.getHTML()
197+
const emitted = useMarkdown ? unescapeXmlTags(raw) : raw
198+
lastEmittedRef.current = emitted
199+
onChangeRef.current(emitted)
188200
}
189201
})
190202

@@ -199,16 +211,14 @@ export function RichTextEditor({
199211
// Reads from refs so only `editor` needs to be in the dep array.
200212
useEffect(() => {
201213
if (!editor || !initialValueRef.current) return
202-
const contentType = !useMarkdownRef.current || isHtmlContent(initialValueRef.current) ? 'html' : 'markdown'
203-
editor.commands.setContent(initialValueRef.current, { emitUpdate: false, contentType })
214+
loadContent(editor, initialValueRef.current, useMarkdownRef.current)
204215
lastEmittedRef.current = initialValueRef.current
205216
}, [editor])
206217

207218
// Sync genuine external value changes (e.g. parent resets the field programmatically).
208219
useEffect(() => {
209220
if (editor && value !== lastEmittedRef.current) {
210-
const contentType = !useMarkdown || isHtmlContent(value) ? 'html' : 'markdown'
211-
editor.commands.setContent(value, { emitUpdate: false, contentType })
221+
loadContent(editor, value, useMarkdown)
212222
lastEmittedRef.current = value
213223
}
214224
}, [editor, value, useMarkdown])

packages/agentflow/src/atoms/VariableInput.test.tsx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createTheme, ThemeProvider } from '@mui/material/styles'
22
import { render, screen } from '@testing-library/react'
33
import * as TiptapReact from '@tiptap/react'
4+
import StarterKit from '@tiptap/starter-kit'
45

56
import { VariableInput, type VariableInputProps } from './VariableInput'
67

@@ -98,4 +99,92 @@ describe('VariableInput', () => {
9899
// The mock editor has getHTML/getMarkdown/commands etc.
99100
expect(onEditorReady).toHaveBeenCalledWith(expect.objectContaining({ getHTML: expect.any(Function) }))
100101
})
102+
103+
describe('useMarkdown derived from rows — StarterKit link extension', () => {
104+
beforeEach(() => {
105+
jest.clearAllMocks()
106+
})
107+
108+
it('passes link:false to StarterKit when rows is not set (single-line field)', () => {
109+
renderVariableInput({ rows: undefined })
110+
111+
expect(StarterKit.configure).toHaveBeenCalledWith(expect.objectContaining({ link: false }))
112+
})
113+
114+
it('does not pass link:false to StarterKit when rows is set (multiline/markdown field)', () => {
115+
renderVariableInput({ rows: 4 })
116+
117+
expect(StarterKit.configure).not.toHaveBeenCalledWith(expect.objectContaining({ link: false }))
118+
})
119+
})
120+
121+
describe('onUpdate always emits markdown regardless of rows', () => {
122+
it('calls onChange with getEditorMarkdown output when rows is not set', () => {
123+
const onChange = jest.fn()
124+
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
125+
renderVariableInput({ onChange, rows: undefined })
126+
127+
const config = useEditorSpy.mock.calls[0][0] as { onUpdate: (args: { editor: unknown }) => void }
128+
const mockEditor = { getMarkdown: () => 'plain url text', getHTML: jest.fn() }
129+
config.onUpdate({ editor: mockEditor })
130+
131+
expect(onChange).toHaveBeenCalledWith('plain url text')
132+
expect(mockEditor.getHTML).not.toHaveBeenCalled()
133+
useEditorSpy.mockRestore()
134+
})
135+
136+
it('calls onChange with getEditorMarkdown output when rows is set', () => {
137+
const onChange = jest.fn()
138+
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
139+
renderVariableInput({ onChange, rows: 4 })
140+
141+
const config = useEditorSpy.mock.calls[0][0] as { onUpdate: (args: { editor: unknown }) => void }
142+
const mockEditor = { getMarkdown: () => '**bold**', getHTML: jest.fn() }
143+
config.onUpdate({ editor: mockEditor })
144+
145+
expect(onChange).toHaveBeenCalledWith('**bold**')
146+
expect(mockEditor.getHTML).not.toHaveBeenCalled()
147+
useEditorSpy.mockRestore()
148+
})
149+
})
150+
151+
describe('XML tag preservation in onUpdate', () => {
152+
it('should unescape entity-escaped XML tags before calling onChange', () => {
153+
const onChange = jest.fn()
154+
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
155+
renderVariableInput({ onChange })
156+
157+
const config = useEditorSpy.mock.calls[0][0] as { onUpdate: (args: { editor: unknown }) => void }
158+
config.onUpdate({ editor: { getMarkdown: () => '&lt;instructions&gt;Be helpful&lt;/instructions&gt;', getHTML: jest.fn() } })
159+
160+
expect(onChange).toHaveBeenCalledWith('<instructions>Be helpful</instructions>')
161+
useEditorSpy.mockRestore()
162+
})
163+
164+
it('should pass through raw XML tags unchanged', () => {
165+
const onChange = jest.fn()
166+
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
167+
renderVariableInput({ onChange })
168+
169+
const config = useEditorSpy.mock.calls[0][0] as { onUpdate: (args: { editor: unknown }) => void }
170+
config.onUpdate({ editor: { getMarkdown: () => '<question>What?</question>', getHTML: jest.fn() } })
171+
172+
expect(onChange).toHaveBeenCalledWith('<question>What?</question>')
173+
useEditorSpy.mockRestore()
174+
})
175+
176+
it('should preserve XML tags mixed with variables', () => {
177+
const onChange = jest.fn()
178+
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
179+
renderVariableInput({ onChange })
180+
181+
const config = useEditorSpy.mock.calls[0][0] as { onUpdate: (args: { editor: unknown }) => void }
182+
config.onUpdate({
183+
editor: { getMarkdown: () => '&lt;context&gt;{{question}}&lt;/context&gt;', getHTML: jest.fn() }
184+
})
185+
186+
expect(onChange).toHaveBeenCalledWith('<context>{{question}}</context>')
187+
useEditorSpy.mockRestore()
188+
})
189+
})
101190
})

0 commit comments

Comments
 (0)