mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
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:
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -203,6 +203,7 @@ export const ar: TranslationResources = {
|
||||
empty: "ابدأ الدردشة مع هذا الوكيل...",
|
||||
scrollToBottom: "قم بالتمرير إلى الأسفل",
|
||||
historyLoadFailed: "تعذر تحميل سجل الوكيل",
|
||||
messageCapped: "تم اقتطاع هذه الرسالة ({{bytes}} بايت).",
|
||||
permission: {
|
||||
plan: "يخطط",
|
||||
required: "الإذن مطلوب",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -203,6 +203,7 @@ export const ja: TranslationResources = {
|
||||
empty: "このエージェントとチャットを始めましょう...",
|
||||
scrollToBottom: "下にスクロール",
|
||||
historyLoadFailed: "エージェントの履歴を読み込めませんでした",
|
||||
messageCapped: "このメッセージは上限で切り詰められました({{bytes}}バイト)。",
|
||||
permission: {
|
||||
plan: "プラン",
|
||||
required: "権限が必要です",
|
||||
|
||||
@@ -203,6 +203,7 @@ export const ko: TranslationResources = {
|
||||
empty: "이 에이전트와 대화를 시작하세요...",
|
||||
scrollToBottom: "맨 아래로 스크롤",
|
||||
historyLoadFailed: "에이전트 기록을 로드할 수 없습니다.",
|
||||
messageCapped: "이 메시지는 길이 제한으로 잘렸습니다({{bytes}}바이트).",
|
||||
permission: {
|
||||
plan: "계획",
|
||||
required: "권한 필요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -203,6 +203,7 @@ export const ru: TranslationResources = {
|
||||
empty: "Начните общаться с этим агентом...",
|
||||
scrollToBottom: "Прокрутить вниз",
|
||||
historyLoadFailed: "Не удалось загрузить историю агента",
|
||||
messageCapped: "Это сообщение было обрезано ({{bytes}} байт).",
|
||||
permission: {
|
||||
plan: "План",
|
||||
required: "Требуется разрешение",
|
||||
|
||||
@@ -203,6 +203,7 @@ export const zhCN: TranslationResources = {
|
||||
empty: "开始和这个 Agent 对话...",
|
||||
scrollToBottom: "滚动到底部",
|
||||
historyLoadFailed: "无法加载智能体历史记录",
|
||||
messageCapped: "此消息已被截断({{bytes}} 字节)。",
|
||||
permission: {
|
||||
plan: "Plan",
|
||||
required: "需要权限",
|
||||
|
||||
Reference in New Issue
Block a user