diff --git a/packages/ui/src/components/chat/message/parts/toolDiffPreview.ts b/packages/ui/src/components/chat/message/parts/toolDiffPreview.ts index 1267e1f1..79ea95f4 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffPreview.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffPreview.ts @@ -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…`; }; diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts index 13fae957..a9250b68 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts @@ -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');