Skip to content

Commit 40223f2

Browse files
authored
Merge pull request Expensify#89744 from Expensify/claude-stripIncompleteMarkdownDuringStreaming
[Payment due @ChavdaSachin] Strip incomplete markdown from Concierge streaming drafts
2 parents b740696 + 583714b commit 40223f2

2 files changed

Lines changed: 274 additions & 5 deletions

File tree

src/pages/inbox/conciergeDraftState.ts

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,170 @@ type BuildConciergeDraftReportActionParams = {
2020
reportID: string;
2121
};
2222

23+
type TextRange = {
24+
start: number;
25+
end: number;
26+
};
27+
28+
const CODE_BLOCK_DELIMITER = '```';
29+
const INLINE_CODE_DELIMITER = '`';
30+
const BOLD_DELIMITER = '**';
31+
const STRIKETHROUGH_DELIMITER = '~~';
32+
33+
function isInAnyRange(position: number, ranges: TextRange[]): boolean {
34+
return ranges.some((range) => position >= range.start && position < range.end);
35+
}
36+
37+
function isEscaped(text: string, index: number): boolean {
38+
let slashCount = 0;
39+
let pos = index - 1;
40+
41+
while (pos >= 0 && text[pos] === '\\') {
42+
slashCount++;
43+
pos--;
44+
}
45+
46+
return slashCount % 2 !== 0;
47+
}
48+
49+
function getCodeRanges(text: string): {ranges: TextRange[]; unclosedCodeBlockStart: number | null} {
50+
const ranges: TextRange[] = [];
51+
let unclosedCodeBlockStart: number | null = null;
52+
53+
for (let pos = 0; pos <= text.length - CODE_BLOCK_DELIMITER.length; pos++) {
54+
if (!text.startsWith(CODE_BLOCK_DELIMITER, pos) || isEscaped(text, pos)) {
55+
continue;
56+
}
57+
58+
if (unclosedCodeBlockStart === null) {
59+
unclosedCodeBlockStart = pos;
60+
} else {
61+
ranges.push({start: unclosedCodeBlockStart, end: pos + CODE_BLOCK_DELIMITER.length});
62+
unclosedCodeBlockStart = null;
63+
}
64+
pos += CODE_BLOCK_DELIMITER.length - 1;
65+
}
66+
67+
let lineStart = 0;
68+
69+
while (lineStart <= text.length) {
70+
const nextNewline = text.indexOf('\n', lineStart);
71+
const lineEnd = nextNewline === -1 ? text.length : nextNewline;
72+
let openingDelimiterIndex: number | null = null;
73+
74+
for (let pos = lineStart; pos < lineEnd; pos++) {
75+
if (text[pos] !== INLINE_CODE_DELIMITER || isEscaped(text, pos) || isInAnyRange(pos, ranges)) {
76+
continue;
77+
}
78+
79+
if (openingDelimiterIndex === null) {
80+
openingDelimiterIndex = pos;
81+
} else {
82+
ranges.push({start: openingDelimiterIndex, end: pos + INLINE_CODE_DELIMITER.length});
83+
openingDelimiterIndex = null;
84+
}
85+
}
86+
87+
if (nextNewline === -1) {
88+
break;
89+
}
90+
lineStart = nextNewline + 1;
91+
}
92+
93+
return {ranges, unclosedCodeBlockStart};
94+
}
95+
96+
function stripUnpairedLastLineDelimiter(text: string, delimiter: string, ignoredRanges: TextRange[] = []): string {
97+
const lastNewline = text.lastIndexOf('\n');
98+
const lastLineStart = lastNewline + 1;
99+
const delimiterIndexes: number[] = [];
100+
101+
for (let pos = lastLineStart; pos <= text.length - delimiter.length; pos++) {
102+
if (!text.startsWith(delimiter, pos) || isEscaped(text, pos) || isInAnyRange(pos, ignoredRanges)) {
103+
continue;
104+
}
105+
106+
delimiterIndexes.push(pos);
107+
pos += delimiter.length - 1;
108+
}
109+
110+
if (delimiterIndexes.length > 0 && delimiterIndexes.length % 2 !== 0) {
111+
return text.substring(0, delimiterIndexes.at(-1));
112+
}
113+
114+
return text;
115+
}
116+
117+
function normalizeDelimiterForExpensiMark(text: string, delimiter: string, replacement: string, ignoredRanges: TextRange[] = []): string {
118+
let result = '';
119+
120+
for (let pos = 0; pos < text.length; pos++) {
121+
if (text.startsWith(delimiter, pos) && !isEscaped(text, pos) && !isInAnyRange(pos, ignoredRanges)) {
122+
result += replacement;
123+
pos += delimiter.length - 1;
124+
continue;
125+
}
126+
127+
result += text[pos];
128+
}
129+
130+
return result;
131+
}
132+
133+
/**
134+
* Strips incomplete markdown constructs from the tail of a streaming markdown
135+
* string so that ExpensiMark doesn't render raw syntax for half-finished
136+
* links, bold, strikethrough, or code blocks. Completed double-delimiter
137+
* emphasis is normalized to ExpensiMark's single-delimiter syntax so the text
138+
* stays styled without leaking raw delimiters while the server-rendered HTML is
139+
* still pending.
140+
*/
141+
function stripIncompleteMarkdown(markdown: string): string {
142+
if (!markdown) {
143+
return markdown;
144+
}
145+
146+
const initialCodeState = getCodeRanges(markdown);
147+
let codeRanges = initialCodeState.ranges;
148+
let result = initialCodeState.unclosedCodeBlockStart === null ? markdown : markdown.substring(0, initialCodeState.unclosedCodeBlockStart);
149+
150+
// Strip incomplete inline code before looking for other markdown so code
151+
// contents don't look like unfinished links or emphasis.
152+
codeRanges = codeRanges.filter((range) => range.end <= result.length);
153+
result = stripUnpairedLastLineDelimiter(result, INLINE_CODE_DELIMITER, codeRanges);
154+
155+
codeRanges = getCodeRanges(result).ranges;
156+
for (let openBracketIndex = result.length - 1; openBracketIndex >= 0; openBracketIndex--) {
157+
if (result[openBracketIndex] !== '[' || isEscaped(result, openBracketIndex) || isInAnyRange(openBracketIndex, codeRanges)) {
158+
continue;
159+
}
160+
161+
const closeBracketIndex = result.indexOf(']', openBracketIndex + 1);
162+
const stripFrom = openBracketIndex > 0 && result[openBracketIndex - 1] === '!' && !isEscaped(result, openBracketIndex - 1) ? openBracketIndex - 1 : openBracketIndex;
163+
164+
if (closeBracketIndex === -1) {
165+
result = result.substring(0, stripFrom);
166+
} else if (result[closeBracketIndex + 1] === '(' && result.indexOf(')', closeBracketIndex + 2) === -1) {
167+
result = result.substring(0, stripFrom);
168+
}
169+
break;
170+
}
171+
172+
codeRanges = getCodeRanges(result).ranges;
173+
result = stripUnpairedLastLineDelimiter(result, BOLD_DELIMITER, codeRanges);
174+
codeRanges = getCodeRanges(result).ranges;
175+
result = normalizeDelimiterForExpensiMark(result, BOLD_DELIMITER, '*', codeRanges);
176+
177+
codeRanges = getCodeRanges(result).ranges;
178+
result = stripUnpairedLastLineDelimiter(result, STRIKETHROUGH_DELIMITER, codeRanges);
179+
codeRanges = getCodeRanges(result).ranges;
180+
result = normalizeDelimiterForExpensiMark(result, STRIKETHROUGH_DELIMITER, '~', codeRanges);
181+
182+
return result;
183+
}
184+
23185
function buildConciergeDraftReportAction({bodyMarkdown, created, finalRenderedHTML, reportActionID, reportID}: BuildConciergeDraftReportActionParams): ReportAction | null {
24-
const html = finalRenderedHTML ?? (bodyMarkdown ? getParsedComment(bodyMarkdown, {reportID}) : '');
186+
const html = finalRenderedHTML ?? (bodyMarkdown ? getParsedComment(stripIncompleteMarkdown(bodyMarkdown), {reportID}) : '');
25187

26188
if (!html) {
27189
return null;
@@ -101,5 +263,5 @@ function applyConciergeDraftEvent(currentDraft: ConciergeDraft | null, event: Co
101263
};
102264
}
103265

104-
export {applyConciergeDraftEvent, getCachedDraft, setCachedDraft};
266+
export {applyConciergeDraftEvent, getCachedDraft, setCachedDraft, stripIncompleteMarkdown};
105267
export type {ConciergeDraft};

tests/unit/pages/inbox/conciergeDraftState.test.ts

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {applyConciergeDraftEvent, getCachedDraft, setCachedDraft} from '@pages/inbox/conciergeDraftState';
1+
import {applyConciergeDraftEvent, getCachedDraft, setCachedDraft, stripIncompleteMarkdown} from '@pages/inbox/conciergeDraftState';
22
import CONST from '@src/CONST';
33

44
const REPORT_ID = '123';
@@ -47,7 +47,7 @@ describe('conciergeDraftState', () => {
4747
expect(draft?.reportAction.actorAccountID).toBe(CONST.ACCOUNT_ID.CONCIERGE);
4848
expect(draft?.reportAction.created).toBe(CREATED);
4949
expect(getFirstMessageHTML(draft)).toContain('<strong>world</strong>');
50-
expect(getFirstMessageText(draft)).toBe('Hello, *world*!');
50+
expect(getFirstMessageText(draft)).toBe('Hello, world!');
5151
});
5252

5353
it('should update the same draft session when a newer sequence arrives', () => {
@@ -79,7 +79,7 @@ describe('conciergeDraftState', () => {
7979
);
8080

8181
expect(staleDraft).toBe(initialDraft);
82-
expect(getFirstMessageText(staleDraft)).toBe('Hello, *world*!');
82+
expect(getFirstMessageText(staleDraft)).toBe('Hello, world!');
8383
});
8484

8585
it('should keep the draft visible through completion and prefer finalRenderedHTML when provided', () => {
@@ -132,6 +132,113 @@ describe('conciergeDraftState', () => {
132132
expect(otherReportDraft).toBe(initialDraft);
133133
});
134134

135+
describe('stripIncompleteMarkdown', () => {
136+
it('returns empty/falsy values unchanged', () => {
137+
expect(stripIncompleteMarkdown('')).toBe('');
138+
});
139+
140+
it('does not alter complete markdown', () => {
141+
const complete = 'Hello *bold* and [link](https://example.com) and `code`';
142+
expect(stripIncompleteMarkdown(complete)).toBe(complete);
143+
});
144+
145+
it('normalizes complete double-delimiter emphasis for ExpensiMark', () => {
146+
expect(stripIncompleteMarkdown('Hello **bold** and ~~strike~~')).toBe('Hello *bold* and ~strike~');
147+
});
148+
149+
// --- Links / Images ---
150+
it('strips an incomplete link with only opening bracket', () => {
151+
expect(stripIncompleteMarkdown('Check out [')).toBe('Check out ');
152+
});
153+
154+
it('strips an incomplete link with text but no closing bracket', () => {
155+
expect(stripIncompleteMarkdown('Check out [this page')).toBe('Check out ');
156+
});
157+
158+
it('strips an incomplete link with bracket closed but no URL', () => {
159+
expect(stripIncompleteMarkdown('Check out [link](')).toBe('Check out ');
160+
});
161+
162+
it('strips an incomplete link with partial URL', () => {
163+
expect(stripIncompleteMarkdown('Check out [link](https://example')).toBe('Check out ');
164+
});
165+
166+
it('preserves a complete link followed by an incomplete one', () => {
167+
expect(stripIncompleteMarkdown('[done](https://a.com) and [broken')).toBe('[done](https://a.com) and ');
168+
});
169+
170+
it('preserves bracketed text that is not a link', () => {
171+
const complete = 'The accepted values are [yes/no] for this setting';
172+
expect(stripIncompleteMarkdown(complete)).toBe(complete);
173+
});
174+
175+
it('strips an incomplete image syntax', () => {
176+
expect(stripIncompleteMarkdown('Here is ![alt')).toBe('Here is ');
177+
});
178+
179+
// --- Bold (**) ---
180+
it('strips trailing unclosed bold', () => {
181+
expect(stripIncompleteMarkdown('Hello **world')).toBe('Hello ');
182+
});
183+
184+
it('strips bare trailing ** delimiter', () => {
185+
expect(stripIncompleteMarkdown('Hello **')).toBe('Hello ');
186+
});
187+
188+
it('preserves complete bold and strips only the unclosed one', () => {
189+
expect(stripIncompleteMarkdown('**done** and **broken')).toBe('*done* and ');
190+
});
191+
192+
// --- Strikethrough (~~) ---
193+
it('strips trailing unclosed strikethrough', () => {
194+
expect(stripIncompleteMarkdown('Hello ~~strike')).toBe('Hello ');
195+
});
196+
197+
// --- Code blocks (```) ---
198+
it('strips an unclosed code block', () => {
199+
expect(stripIncompleteMarkdown('Here:\n```\ncode')).toBe('Here:\n');
200+
});
201+
202+
it('preserves a complete code block', () => {
203+
const complete = 'Before\n```\ncode\n```\nAfter';
204+
expect(stripIncompleteMarkdown(complete)).toBe(complete);
205+
});
206+
207+
it('preserves a complete code block ending at the closing fence', () => {
208+
const complete = 'Before\n```\ncode\n```';
209+
expect(stripIncompleteMarkdown(complete)).toBe(complete);
210+
});
211+
212+
// --- Inline code (`) ---
213+
it('strips trailing unclosed inline code', () => {
214+
expect(stripIncompleteMarkdown('Run `command')).toBe('Run ');
215+
});
216+
217+
it('preserves complete inline code', () => {
218+
const complete = 'Run `command` now';
219+
expect(stripIncompleteMarkdown(complete)).toBe(complete);
220+
});
221+
222+
it('preserves markdown-looking text inside complete inline code', () => {
223+
const complete = 'Use `[accountID]` and `**not bold` in the payload';
224+
expect(stripIncompleteMarkdown(complete)).toBe(complete);
225+
});
226+
227+
// --- Streaming integration ---
228+
it('keeps complete double-delimiter bold styled without showing raw delimiters during streaming', () => {
229+
const draft = applyConciergeDraftEvent(null, createDraftEvent({bodyMarkdown: 'Hello **bold**!'}), REPORT_ID);
230+
231+
expect(getFirstMessageHTML(draft)).toContain('<strong>bold</strong>');
232+
expect(getFirstMessageHTML(draft)).not.toContain('*');
233+
});
234+
235+
it('strips incomplete markdown during a streaming draft event', () => {
236+
const draft = applyConciergeDraftEvent(null, createDraftEvent({bodyMarkdown: 'Check [this link'}), REPORT_ID);
237+
// The raw '[this link' syntax should NOT appear in the rendered HTML
238+
expect(getFirstMessageHTML(draft)).not.toContain('[this link');
239+
});
240+
});
241+
135242
describe('draftCache', () => {
136243
// Always start clean so tests don't leak state into each other.
137244
beforeEach(() => {

0 commit comments

Comments
 (0)