Prevent oversized assistant messages from crashing the timeline (#4166)

* fix(app): cap oversized assistant messages

A single assistant message can expand into enough rendered Markdown nodes to crash the renderer. Limit the Markdown input while preserving the complete timeline value and report its full byte size.

* fix(app): bound oversized stream processing

Cap the render input before paced reveal and defer full-message byte counting until completion. Reuse the existing grapheme boundary policy so capped text never ends in a partial glyph.

* fix(app): preserve capped text without segmentation

Hermes lacks Intl.Segmenter, while the shared reveal boundary intentionally returns zero without it. Keep that contract and fall back locally to a surrogate-safe 32K prefix.
This commit is contained in:
Mohamed Boudra
2026-09-01 18:06:25 +02:00
committed by GitHub
parent 95575ad05a
commit 06fae60809
13 changed files with 170 additions and 1 deletions
@@ -0,0 +1,32 @@
import { expect, test } from "../support/fixtures";
import { getUtf8ByteLength } from "../../src/components/assistant-message-render-limit";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test("caps a newline-heavy assistant message before rendering markdown", async ({ page }) => {
const hiddenTail = "HIDDEN_OVERSIZED_MESSAGE_TAIL";
const response = `${"x\n".repeat(16_000)}${hiddenTail}`;
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-message-render-limit-",
title: "Assistant message render limit",
initialPrompt: "Render the configured oversized response.",
featureValues: { mockAssistantResponse: response },
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await openAgentRoute(page, agent);
const assistantMessage = page.getByTestId("assistant-message").last();
await expect(assistantMessage).toBeVisible({ timeout: 30_000 });
await expect(assistantMessage).not.toContainText(hiddenTail);
const notice = assistantMessage.getByTestId("assistant-message-capped-notice");
await expect(notice).toBeVisible();
await expect(notice).toHaveText(
`This message was capped (${getUtf8ByteLength(response)} bytes).`,
);
await expect(notice).toHaveCSS("font-style", "italic");
} finally {
await agent.cleanup();
}
});
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT,
capAssistantMessageForRender,
getUtf8ByteLength,
} from "./assistant-message-render-limit";
describe("assistant message render limit", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});
it("leaves messages at the limit unchanged", () => {
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT);
expect(capAssistantMessageForRender(message)).toEqual({ text: message, capped: false });
});
it("caps oversized messages before markdown rendering", () => {
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + 1);
expect(capAssistantMessageForRender(message)).toEqual({
text: "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT),
capped: true,
});
});
it("does not split a grapheme cluster at the boundary", () => {
const prefix = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT - 1);
expect(capAssistantMessageForRender(`${prefix}étail`)).toEqual({
text: prefix,
capped: true,
});
});
it("counts the complete message size in UTF-8 bytes", () => {
expect(getUtf8ByteLength("aé😀")).toBe(7);
});
it("keeps a bounded prefix when grapheme segmentation is unavailable", async () => {
vi.resetModules();
vi.stubGlobal("Intl", { ...Intl, Segmenter: undefined });
const { capAssistantMessageForRender: capWithoutSegmenter } =
await import("./assistant-message-render-limit");
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + 1);
expect(capWithoutSegmenter(message)).toEqual({
text: "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT),
capped: true,
});
});
});
@@ -0,0 +1,52 @@
import { clampToSafeRevealBoundary, isTextRevealPacingSupported } from "@/agent-stream/text-reveal";
export const ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT = 32_000;
export interface CappedAssistantMessage {
text: string;
capped: boolean;
}
export function getUtf8ByteLength(message: string): number {
let bytes = 0;
for (let index = 0; index < message.length; index += 1) {
const codeUnit = message.charCodeAt(index);
if (codeUnit < 0x80) {
bytes += 1;
} else if (codeUnit < 0x800) {
bytes += 2;
} else if (
codeUnit >= 0xd800 &&
codeUnit <= 0xdbff &&
index + 1 < message.length &&
message.charCodeAt(index + 1) >= 0xdc00 &&
message.charCodeAt(index + 1) <= 0xdfff
) {
bytes += 4;
index += 1;
} else {
bytes += 3;
}
}
return bytes;
}
export function capAssistantMessageForRender(message: string): CappedAssistantMessage {
if (message.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT) {
return { text: message, capped: false };
}
let end = ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT;
if (isTextRevealPacingSupported()) {
end = clampToSafeRevealBoundary(message, end);
} else {
const finalCodeUnit = message.charCodeAt(end - 1);
if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) {
end -= 1;
}
}
return { text: message.slice(0, end), capped: true };
}
+23 -1
View File
@@ -116,6 +116,7 @@ import {
markdownCopyTableCellDataSet,
type MarkdownCopyInlineTag,
} from "@/assistant-selection-copy/markup";
import { capAssistantMessageForRender, getUtf8ByteLength } from "./assistant-message-render-limit";
export type { InlinePathTarget } from "@/assistant-file-links";
export type { AssistantForkTarget };
@@ -763,6 +764,13 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
containerCompactBottom: {
paddingBottom: 0,
},
cappedNotice: {
marginTop: theme.spacing[3],
fontFamily: theme.fontFamily.ui,
fontSize: theme.fontSize.base,
fontStyle: "italic",
color: theme.colors.foregroundMuted,
},
imageFrame: {
width: "100%",
minHeight: 160,
@@ -1488,10 +1496,16 @@ export const AssistantMessage = memo(function AssistantMessage({
spacing = "default",
phase,
}: AssistantMessageProps) {
const { t } = useTranslation();
const markdownParser = useMemo(createAssistantMarkdownParser, []);
const renderedMessage = useMemo(() => capAssistantMessageForRender(message), [message]);
// Paint a paced prefix while the turn is streaming so text arrives at a steady
// rate instead of in whatever lumps the daemon's coalescing window produced.
const revealedMessage = useRevealedText(message, phase);
const revealedMessage = useRevealedText(renderedMessage.text, phase);
const fullMessageByteLength = useMemo(
() => (renderedMessage.capped && phase === "complete" ? getUtf8ByteLength(message) : null),
[message, phase, renderedMessage.capped],
);
const fileLinkActions = useAssistantFileLinkActions();
const handleMarkdownLinkPress = useStableEvent((url: string) => {
@@ -1970,6 +1984,14 @@ export const AssistantMessage = memo(function AssistantMessage({
/>
</AssistantMessageBlockContainer>
))}
{fullMessageByteLength !== null ? (
<Text
testID="assistant-message-capped-notice"
style={assistantMessageStylesheet.cappedNotice}
>
{t("agentStream.messageCapped", { bytes: fullMessageByteLength })}
</Text>
) : null}
</View>
);
});
+1
View File
@@ -203,6 +203,7 @@ export const ar: TranslationResources = {
empty: "ابدأ الدردشة مع هذا الوكيل...",
scrollToBottom: "قم بالتمرير إلى الأسفل",
historyLoadFailed: "تعذر تحميل سجل الوكيل",
messageCapped: "تم اقتطاع هذه الرسالة ({{bytes}} بايت).",
permission: {
plan: "يخطط",
required: "الإذن مطلوب",
+1
View File
@@ -200,6 +200,7 @@ export const en = {
empty: "Start chatting with this agent...",
scrollToBottom: "Scroll to bottom",
historyLoadFailed: "Couldn't load agent history",
messageCapped: "This message was capped ({{bytes}} bytes).",
permission: {
plan: "Plan",
required: "Permission Required",
+1
View File
@@ -203,6 +203,7 @@ export const es: TranslationResources = {
empty: "Comience a chatear con este agente...",
scrollToBottom: "Desplazarse hacia abajo",
historyLoadFailed: "No se pudo cargar el historial del agente",
messageCapped: "Este mensaje fue truncado ({{bytes}} bytes).",
permission: {
plan: "Plan",
required: "Permiso requerido",
+1
View File
@@ -205,6 +205,7 @@ export const fr: TranslationResources = {
empty: "Commencez à discuter avec cet agent...",
scrollToBottom: "Faire défiler vers le bas",
historyLoadFailed: "Impossible de charger l’historique de l’agent",
messageCapped: "Ce message a été tronqué ({{bytes}} octets).",
permission: {
plan: "Plan",
required: "Autorisation requise",
+1
View File
@@ -203,6 +203,7 @@ export const ja: TranslationResources = {
empty: "このエージェントとチャットを始めましょう...",
scrollToBottom: "下にスクロール",
historyLoadFailed: "エージェントの履歴を読み込めませんでした",
messageCapped: "このメッセージは上限で切り詰められました({{bytes}}バイト)。",
permission: {
plan: "プラン",
required: "権限が必要です",
+1
View File
@@ -203,6 +203,7 @@ export const ko: TranslationResources = {
empty: "이 에이전트와 대화를 시작하세요...",
scrollToBottom: "맨 아래로 스크롤",
historyLoadFailed: "에이전트 기록을 로드할 수 없습니다.",
messageCapped: "이 메시지는 길이 제한으로 잘렸습니다({{bytes}}바이트).",
permission: {
plan: "계획",
required: "권한 필요",
+1
View File
@@ -203,6 +203,7 @@ export const ptBR: TranslationResources = {
empty: "Comece a conversar com este agente...",
scrollToBottom: "Rolar para o fim",
historyLoadFailed: "Não foi possível carregar o histórico do agente",
messageCapped: "Esta mensagem foi truncada ({{bytes}} bytes).",
permission: {
plan: "Plano",
required: "Permissão necessária",
+1
View File
@@ -203,6 +203,7 @@ export const ru: TranslationResources = {
empty: "Начните общаться с этим агентом...",
scrollToBottom: "Прокрутить вниз",
historyLoadFailed: "Не удалось загрузить историю агента",
messageCapped: "Это сообщение было обрезано ({{bytes}} байт).",
permission: {
plan: "План",
required: "Требуется разрешение",
+1
View File
@@ -203,6 +203,7 @@ export const zhCN: TranslationResources = {
empty: "开始和这个 Agent 对话...",
scrollToBottom: "滚动到底部",
historyLoadFailed: "无法加载智能体历史记录",
messageCapped: "此消息已被截断({{bytes}} 字节)。",
permission: {
plan: "Plan",
required: "需要权限",