fix(chat): keep the diff preview bound off a surrogate pair

The 256 KiB budget for an oversized tool diff preview counts UTF-16 units, so
the cut can land between the two halves of an astral character - an emoji in a
patched string, for instance. The preview then ended in a lone surrogate that
renders as the replacement glyph.

Step back one unit when the boundary splits a pair.
This commit is contained in:
Bohdan Triapitsyn
2026-09-05 14:48:17 +03:00
parent e24d2b2cbd
commit 74b8c77d49
2 changed files with 25 additions and 1 deletions
@@ -22,8 +22,20 @@ const getPreviewEnd = (diff: string): number | null => {
export const isToolDiffPreviewOversized = (diff: string): boolean => getPreviewEnd(diff) !== null;
/**
* The character budget counts UTF-16 units, so it can land between the two
* halves of an astral character (an emoji in a patched string, say). Cutting
* there leaves a lone surrogate that renders as the replacement glyph, so step
* back one unit when the boundary splits a pair.
*/
const withoutSplitSurrogate = (diff: string, previewEnd: number): number => {
const lastUnit = diff.charCodeAt(previewEnd - 1);
const isHighSurrogate = lastUnit >= 0xd800 && lastUnit <= 0xdbff;
return isHighSurrogate ? previewEnd - 1 : previewEnd;
};
export const getToolDiffPreviewText = (diff: string): string => {
const previewEnd = getPreviewEnd(diff);
if (previewEnd === null) return diff;
return `${diff.slice(0, previewEnd).trimEnd()}\n…`;
return `${diff.slice(0, withoutSplitSurrogate(diff, previewEnd)).trimEnd()}\n…`;
};
@@ -223,6 +223,18 @@ describe('toolDiffUtils', () => {
expect(preview.length).toBe(TOOL_DIFF_PREVIEW_MAX_CHARS + 2);
});
test('does not cut an astral character in half at the character limit', () => {
// The boundary lands between the two halves of the emoji, which would
// otherwise leave a lone surrogate rendering as the replacement glyph.
const patch = `+${'x'.repeat(TOOL_DIFF_PREVIEW_MAX_CHARS - 2)}\u{1F600}${'y'.repeat(100)}`;
const preview = getToolDiffPreviewText(patch);
const body = preview.slice(0, -2);
expect(isToolDiffPreviewOversized(patch)).toBe(true);
expect(body).toBe(`+${'x'.repeat(TOOL_DIFF_PREVIEW_MAX_CHARS - 2)}`);
expect(/[\uD800-\uDFFF]/.test(body)).toBe(false);
});
test('preserves diff previews at the character and line limits', () => {
const characterLimit = 'x'.repeat(TOOL_DIFF_PREVIEW_MAX_CHARS);
const lineLimit = Array.from({ length: TOOL_DIFF_PREVIEW_MAX_LINES }, () => '+line').join('\n');