diff --git a/packages/app/e2e/browser/assistant-message-render-limit.spec.ts b/packages/app/e2e/browser/assistant-message-render-limit.spec.ts new file mode 100644 index 0000000000..0502d1375b --- /dev/null +++ b/packages/app/e2e/browser/assistant-message-render-limit.spec.ts @@ -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(); + } +}); diff --git a/packages/app/src/components/assistant-message-render-limit.test.ts b/packages/app/src/components/assistant-message-render-limit.test.ts new file mode 100644 index 0000000000..a73a7b5dfa --- /dev/null +++ b/packages/app/src/components/assistant-message-render-limit.test.ts @@ -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, + }); + }); +}); diff --git a/packages/app/src/components/assistant-message-render-limit.ts b/packages/app/src/components/assistant-message-render-limit.ts new file mode 100644 index 0000000000..cdc911442a --- /dev/null +++ b/packages/app/src/components/assistant-message-render-limit.ts @@ -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 }; +} diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 59079efee2..54459ecda8 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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({ /> ))} + {fullMessageByteLength !== null ? ( + + {t("agentStream.messageCapped", { bytes: fullMessageByteLength })} + + ) : null} ); }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index ed12f32ed6..ae659573b3 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -203,6 +203,7 @@ export const ar: TranslationResources = { empty: "ابدأ الدردشة مع هذا الوكيل...", scrollToBottom: "قم بالتمرير إلى الأسفل", historyLoadFailed: "تعذر تحميل سجل الوكيل", + messageCapped: "تم اقتطاع هذه الرسالة ({{bytes}} بايت).", permission: { plan: "يخطط", required: "الإذن مطلوب", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index dbb90fb1f9..64347ae53e 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 7986619f57..ea688629e4 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 69bc8da578..d7be667dc6 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 036fcba571..b823c5e0b9 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -203,6 +203,7 @@ export const ja: TranslationResources = { empty: "このエージェントとチャットを始めましょう...", scrollToBottom: "下にスクロール", historyLoadFailed: "エージェントの履歴を読み込めませんでした", + messageCapped: "このメッセージは上限で切り詰められました({{bytes}}バイト)。", permission: { plan: "プラン", required: "権限が必要です", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index bae2910423..870d2523ac 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -203,6 +203,7 @@ export const ko: TranslationResources = { empty: "이 에이전트와 대화를 시작하세요...", scrollToBottom: "맨 아래로 스크롤", historyLoadFailed: "에이전트 기록을 로드할 수 없습니다.", + messageCapped: "이 메시지는 길이 제한으로 잘렸습니다({{bytes}}바이트).", permission: { plan: "계획", required: "권한 필요", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index e84156e096..fe3c042bbf 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index aab8a0245e..13cb3122e7 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -203,6 +203,7 @@ export const ru: TranslationResources = { empty: "Начните общаться с этим агентом...", scrollToBottom: "Прокрутить вниз", historyLoadFailed: "Не удалось загрузить историю агента", + messageCapped: "Это сообщение было обрезано ({{bytes}} байт).", permission: { plan: "План", required: "Требуется разрешение", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index c6ab40af4c..3ad245fbcf 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -203,6 +203,7 @@ export const zhCN: TranslationResources = { empty: "开始和这个 Agent 对话...", scrollToBottom: "滚动到底部", historyLoadFailed: "无法加载智能体历史记录", + messageCapped: "此消息已被截断({{bytes}} 字节)。", permission: { plan: "Plan", required: "需要权限",