diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index c4a6d71f3..8041b3493 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -364,7 +364,12 @@ export function extractTextFromMessage(message: Message) { const THINK_OPEN_TAG = ""; const THINK_TAG_RE = /\s*([\s\S]*?)\s*<\/think>/g; -function splitInlineReasoning(content: string) { +interface InlineReasoningSplit { + content: string; + reasoning: string | null; +} + +function splitInlineReasoning(content: string): InlineReasoningSplit { const reasoningParts: string[] = []; // First pass: strip every fully closed `...` pair and @@ -401,11 +406,29 @@ function splitInlineReasoning(content: string) { }; } +// The split is re-derived on every render: `hasContent`, `hasReasoning`, +// `extractContentFromMessage` and `extractReasoningContentFromMessage` all run +// over the whole message list on each stream chunk, so an unmemoized scan costs +// O(total content) per chunk — quadratic across a long run. Cache per message +// object, keyed by the exact content string it was derived from so a message +// whose `content` is reassigned recomputes instead of serving a stale split. +const inlineReasoningCache = new WeakMap< + object, + { content: string; split: InlineReasoningSplit } +>(); + function splitInlineReasoningFromAIMessage(message: Message) { if (message.type !== "ai" || typeof message.content !== "string") { return null; } - return splitInlineReasoning(message.content); + const content = message.content; + const cached = inlineReasoningCache.get(message); + if (cached?.content === content) { + return cached.split; + } + const split = splitInlineReasoning(content); + inlineReasoningCache.set(message, { content, split }); + return split; } export function extractContentFromMessage(message: Message) { @@ -454,7 +477,7 @@ export function extractReasoningContentFromMessage(message: Message) { } } if (typeof message.content === "string") { - return splitInlineReasoning(message.content).reasoning; + return splitInlineReasoningFromAIMessage(message)?.reasoning ?? null; } return null; } @@ -507,7 +530,9 @@ export function hasReasoning(message: Message) { return (part as unknown as { type: "thinking" })?.type === "thinking"; } if (typeof message.content === "string") { - return splitInlineReasoning(message.content).reasoning !== null; + return ( + (splitInlineReasoningFromAIMessage(message)?.reasoning ?? null) !== null + ); } return false; } @@ -567,14 +592,26 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) { } export function isHiddenFromUIMessage(message: Message) { + if (message.additional_kwargs?.hide_from_ui === true) { + return true; + } + if ( + typeof message.name === "string" && + HIDDEN_CONTROL_MESSAGE_NAMES.has(message.name) + ) { + return true; + } + // Only the human branch consults the text. Extracting it up front made every + // caller pay a full content scan for every AI message it was about to + // discard, and this predicate runs over the whole message list on each + // stream chunk (grouping, dedup, human-input state). + if (message.type !== "human") { + return false; + } const content = extractTextFromMessage(message); return ( - message.additional_kwargs?.hide_from_ui === true || - (typeof message.name === "string" && - HIDDEN_CONTROL_MESSAGE_NAMES.has(message.name)) || - (message.type === "human" && - content.includes("") && - stripUploadedFilesTag(content).length === 0) + content.includes("") && + stripUploadedFilesTag(content).length === 0 ); } diff --git a/frontend/tests/unit/core/messages/utils.test.ts b/frontend/tests/unit/core/messages/utils.test.ts index 106ca524e..4c0e69c87 100644 --- a/frontend/tests/unit/core/messages/utils.test.ts +++ b/frontend/tests/unit/core/messages/utils.test.ts @@ -14,6 +14,7 @@ import { hasContent, hasReasoning, isAssistantMessageGroupStreaming, + isHiddenFromUIMessage, parseUploadedFiles, stripInternalMarkers, stripUploadedFilesTag, @@ -295,6 +296,96 @@ describe("inline tag splitting", () => { expect(extractContentFromMessage(message)).toBe("Documentation: `"); expect(extractReasoningContentFromMessage(message)).toBeNull(); }); + + test("re-splits when the same message object gets new content", () => { + // Streaming replaces `content` on the accumulating message, so a split + // cached against the message object must be keyed by the content it was + // derived from. + const message = aiMessage("firstone"); + + expect(extractContentFromMessage(message)).toBe("one"); + expect(extractReasoningContentFromMessage(message)).toBe("first"); + + (message as { content: string }).content = "secondtwo"; + + expect(extractContentFromMessage(message)).toBe("two"); + expect(extractReasoningContentFromMessage(message)).toBe("second"); + expect(hasReasoning(message)).toBe(true); + }); +}); + +describe("isHiddenFromUIMessage", () => { + function contentReadCounter( + message: Omit, + content: string, + ) { + const counter = { reads: 0 }; + const probe = { + ...message, + get content() { + counter.reads++; + return content; + }, + } as unknown as Message; + return { probe, counter }; + } + + test("does not read AI content to decide visibility", () => { + // Only the human branch consults the text, but this predicate runs over + // every message on every stream chunk (grouping, dedup, human-input + // state), so reading AI content here costs a full content scan per + // message per chunk. + const { probe, counter } = contentReadCounter( + { id: "ai-visible", type: "ai" } as Message, + "long reasoninga long streamed answer", + ); + + expect(isHiddenFromUIMessage(probe)).toBe(false); + expect(counter.reads).toBe(0); + }); + + test("does not read content when a control message name already hides it", () => { + const { probe, counter } = contentReadCounter( + { id: "summary-1", type: "ai", name: "summary" } as Message, + "compressed context", + ); + + expect(isHiddenFromUIMessage(probe)).toBe(true); + expect(counter.reads).toBe(0); + }); + + test("still hides a human message that is only slash skill activation", () => { + const message = { + id: "slash-activation", + type: "human", + content: + "\n# SKILL.md\n", + } as Message; + + expect(isHiddenFromUIMessage(message)).toBe(true); + }); + + test("keeps a human message that carries real text alongside the activation", () => { + const message = { + id: "slash-activation-with-task", + type: "human", + content: + "\n# SKILL.md\n\nreal user task", + } as Message; + + expect(isHiddenFromUIMessage(message)).toBe(false); + }); + + test("hides any message flagged with hide_from_ui", () => { + const message = { + id: "hidden-1", + type: "human", + content: "internal reply", + additional_kwargs: { hide_from_ui: true }, + } as Message; + + expect(isHiddenFromUIMessage(message)).toBe(true); + }); }); describe("human message internal context stripping", () => {