import type { AIMessage, Message } from "@langchain/langgraph-sdk"; import { FENCE_MARKER_RE, INDENTED_CODE_RE } from "@/core/streamdown/fences"; interface GenericMessageGroup { type: T; id: string | undefined; messages: Message[]; } interface HumanMessageGroup extends GenericMessageGroup<"human"> {} interface AssistantProcessingGroup extends GenericMessageGroup<"assistant:processing"> {} interface AssistantMessageGroup extends GenericMessageGroup<"assistant"> {} interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {} interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {} interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {} export type MessageGroup = | HumanMessageGroup | AssistantProcessingGroup | AssistantMessageGroup | AssistantPresentFilesGroup | AssistantClarificationGroup | AssistantSubagentGroup; const HIDDEN_CONTROL_MESSAGE_NAMES = new Set([ "summary", "loop_warning", "todo_reminder", "todo_completion_reminder", ]); export function getMessageGroups( messages: Message[], { isCurrentTurnLoading = false }: { isCurrentTurnLoading?: boolean } = {}, ): MessageGroup[] { if (messages.length === 0) { return []; } const groups: MessageGroup[] = []; const currentTurnStartIndex = isCurrentTurnLoading ? findCurrentTurnStartIndex(messages) : -1; // Returns the last group if it can still accept tool messages // (i.e. it's an in-flight processing group, not a terminal human/assistant group). function lastOpenGroup() { const last = groups[groups.length - 1]; if ( last && last.type !== "human" && last.type !== "assistant" && last.type !== "assistant:clarification" ) { return last; } return null; } for (const [messageIndex, message] of messages.entries()) { if (isHiddenFromUIMessage(message)) { continue; } if (message.type === "human") { groups.push({ id: message.id, type: "human", messages: [message] }); continue; } if (message.type === "tool") { if (isClarificationToolMessage(message)) { // Add to the preceding processing group to preserve tool-call association, // then also open a standalone clarification group for prominent display. lastOpenGroup()?.messages.push(message); groups.push({ id: message.id, type: "assistant:clarification", messages: [message], }); } else { const open = lastOpenGroup(); if (open) { open.messages.push(message); } else { // Fallback for orphan tool messages — LangGraph `messages-tuple` can // emit tool-result events out of order or replay them from subagent // state (e.g. bash subagent under LocalSandboxProvider with // allow_host_bash). When that happens, the tool message arrives after // a terminal group and lastOpenGroup() returns null. Previously we // dropped the message with console.error, silently hiding the tool // result from the UI. Attach to the most recent group instead so the // user can still see what the agent did. const lastGroup = groups[groups.length - 1]; if (lastGroup) { lastGroup.messages.push(message); } else { // Leading orphan: `groups` is empty when this tool message // arrives. Two paths reach here: (1) history pagination cuts by // event seq, not turn boundaries, so the first loaded page begins // mid-turn with a tool result whose AI tool-call sits on an // unloaded older page (#4399); (2) the tool message is preceded // only by hidden control messages. Open a processing group so it // stays visible instead of being dropped with a per-render console // error. // // Only case (1) self-heals — loading the older page re-groups the // tool under its real turn. Case (2), and any truly orphaned tool // with no AI antecedent, has no page to load: the group persists // and renders as an empty ChainOfThought shell (convertToSteps // emits steps only for `type === "ai"`). That empty shell is an // accepted degradation — still a net win over dropping the result // and firing console.error every render. groups.push({ id: message.id, type: "assistant:processing", messages: [message], }); } } } continue; } if (message.type === "ai") { // A message with answer content and no tool calls becomes its own // assistant bubble below, which already renders the message's // reasoning_content inside the bubble's collapsible. Such a // message must NOT also feed the processing group, or the ChainOfThought // panel above the bubble paints the identical reasoning a second time // (#3868). Intermediate reasoning (no content) and tool-calling steps // still belong in the processing group. // A content-only message is not necessarily the final answer while its // turn is still streaming: providers can append tool-call chunks to the // same message later. Keep that unresolved message in the processing // group so its visible text does not jump from an assistant bubble into // the steps panel when the tool call arrives (#4304). // A reasoning-bearing answer is treated as terminal until tool calls // actually arrive. If they do arrive on that same message, it is // deliberately reclassified as processing so its tool activity remains // visible with the text that introduced it. // Non-empty content arrays can contain only Anthropic thinking blocks. // Require content the answer renderer can actually display. const hasAnswerContent = extractContentFromMessage(message).length > 0; const isUnresolvedAssistantText = currentTurnStartIndex >= 0 && messageIndex > currentTurnStartIndex && hasAnswerContent && !hasToolCalls(message) && // A provider that has already supplied reasoning with answer text is // completing an answer, not merely streaming a pre-tool narration. // Keep it out of the processing disclosure while the turn is active. !hasReasoning(message); const becomesAssistantBubble = hasAnswerContent && !hasToolCalls(message) && !isUnresolvedAssistantText; if (hasPresentFiles(message)) { groups.push({ id: message.id, type: "assistant:present-files", messages: [message], }); } else if (hasSubagent(message)) { groups.push({ id: message.id, type: "assistant:subagent", messages: [message], }); } else if ( !becomesAssistantBubble && (hasReasoning(message) || hasToolCalls(message) || isUnresolvedAssistantText) ) { const lastGroup = groups[groups.length - 1]; // Accumulate consecutive intermediate AI messages into one processing group. if (lastGroup?.type !== "assistant:processing") { groups.push({ id: message.id, type: "assistant:processing", messages: [message], }); } else { lastGroup.messages.push(message); } } if (becomesAssistantBubble) { groups.push({ id: message.id, type: "assistant", messages: [message] }); } } } return groups; } export function getBranchableAssistantGroupIds( groups: MessageGroup[], isCurrentTurnLoading: boolean, ): Set { // Hidden messages were already removed by getMessageGroups, matching the // backend's branch checkpoint visibility rules. Within each visible human // turn, branching is exposed only when the final AI-bearing group is a // terminal assistant text group. Processing, present-files, and subagent // groups do not render assistant actions. const branchableGroupIds = new Set(); let lastAIGroup: MessageGroup | null = null; const completeTurn = () => { if (lastAIGroup?.type === "assistant" && lastAIGroup.id) { branchableGroupIds.add(lastAIGroup.id); } lastAIGroup = null; }; for (const group of groups) { if (group.type === "human") { completeTurn(); continue; } if (group.messages.some((message) => message.type === "ai")) { lastAIGroup = group; } } if (!isCurrentTurnLoading) { completeTurn(); } return branchableGroupIds; } export type EditableTurn = { humanMessage: Message; }; function isTerminalAssistantTextMessage(message: Message | undefined): boolean { return ( message?.type === "ai" && Boolean(extractTextFromMessage(message).trim()) && !hasToolCalls(message) ); } export function getLatestEditableTurn( groups: MessageGroup[], isCurrentTurnLoading: boolean, ): EditableTurn | null { if (isCurrentTurnLoading) { return null; } let candidate: EditableTurn | null = null; let currentHumanGroup: MessageGroup | null = null; let currentTurnGroups: MessageGroup[] = []; let lastAIGroup: MessageGroup | null = null; const completeTurn = () => { if (!currentHumanGroup) { currentTurnGroups = []; lastAIGroup = null; return; } const humanMessage = currentHumanGroup?.messages.find( (message) => message.type === "human" && message.id, ); let assistantMessage: Message | undefined; for (let i = (lastAIGroup?.messages.length ?? 0) - 1; i >= 0; i -= 1) { const message = lastAIGroup?.messages[i]; if (message?.type === "ai" && message.id) { assistantMessage = message; break; } } if ( currentHumanGroup && lastAIGroup?.type === "assistant" && humanMessage && isTerminalAssistantTextMessage(assistantMessage) ) { candidate = { humanMessage, }; } else { candidate = null; } currentHumanGroup = null; currentTurnGroups = []; lastAIGroup = null; }; for (const group of groups) { if (group.type === "human") { completeTurn(); currentHumanGroup = group; currentTurnGroups = [group]; continue; } if (currentHumanGroup) { currentTurnGroups.push(group); } if (group.messages.some((message) => message.type === "ai")) { lastAIGroup = group; } } completeTurn(); return candidate; } export function groupMessages( messages: Message[], mapper: (group: MessageGroup) => T, ): T[] { return getMessageGroups(messages) .map(mapper) .filter((result) => result !== undefined && result !== null) as T[]; } export function getAssistantTurnUsageMessages(groups: MessageGroup[]) { const usageMessagesByGroupIndex: Array = Array.from( { length: groups.length }, () => null, ); let turnStartIndex: number | null = null; for (const [index, group] of groups.entries()) { if (group.type === "human") { turnStartIndex = null; continue; } turnStartIndex ??= index; const nextGroup = groups[index + 1]; const isTurnEnd = !nextGroup || nextGroup.type === "human"; if (!isTurnEnd) { continue; } usageMessagesByGroupIndex[index] = groups .slice(turnStartIndex, index + 1) .flatMap((currentGroup) => currentGroup.messages) .filter((message) => message.type === "ai"); turnStartIndex = null; } return usageMessagesByGroupIndex; } type MessageMetadataLookup = ( message: Message, index: number, ) => { streamMetadata?: Record } | undefined; export type StreamMetadataSnapshot = { ids: ReadonlyMap>; messages: ReadonlyMap>; }; export type StreamingMessageLookup = { ids: ReadonlySet; messages: ReadonlySet; }; export function areStreamMetadataSnapshotsEqual( left: StreamMetadataSnapshot, right: StreamMetadataSnapshot, ) { if ( left.ids.size !== right.ids.size || left.messages.size !== right.messages.size ) { return false; } for (const [id, metadata] of left.ids) { if (right.ids.get(id) !== metadata) { return false; } } for (const [message, metadata] of left.messages) { if (right.messages.get(message) !== metadata) { return false; } } return true; } export function getStreamMetadataSnapshot( messages: Message[], getMessagesMetadata?: MessageMetadataLookup, ): StreamMetadataSnapshot { const metadataById = new Map>(); const metadataByMessage = new Map>(); messages.forEach((message, index) => { const streamMetadata = getMessagesMetadata?.( message, index, )?.streamMetadata; if (!streamMetadata) { return; } if (typeof message.id === "string" && message.id.length > 0) { metadataById.set(message.id, streamMetadata); } else { metadataByMessage.set(message, streamMetadata); } }); return { ids: metadataById, messages: metadataByMessage, }; } export function getStreamingMessageLookup( messages: Message[], isStreaming: boolean, getMessagesMetadata?: MessageMetadataLookup, settledMetadata?: StreamMetadataSnapshot, ): StreamingMessageLookup { const streamingMessageIds = new Set(); const streamingMessages = new Set(); if (!isStreaming) { return { ids: streamingMessageIds, messages: streamingMessages, }; } messages.forEach((message, index) => { const streamMetadata = getMessagesMetadata?.( message, index, )?.streamMetadata; if (!streamMetadata) { return; } if (typeof message.id === "string" && message.id.length > 0) { // MessageTupleManager retains metadata until the whole stream instance is // cleared. A later run therefore exposes the completed turn's metadata // again. Only an unchanged metadata object is stale: a new object for the // same message id means that message received another stream event. if (settledMetadata?.ids.get(message.id) === streamMetadata) { return; } streamingMessageIds.add(message.id); } else if (settledMetadata?.messages.get(message) === streamMetadata) { return; } streamingMessages.add(message); }); return { ids: streamingMessageIds, messages: streamingMessages, }; } export function isAssistantMessageGroupStreaming( groupMessages: Message[], streamingMessages: StreamingMessageLookup, ) { return groupMessages.some((message) => { if (message.type !== "ai") { return false; } return ( (typeof message.id === "string" && message.id.length > 0 && streamingMessages.ids.has(message.id)) || streamingMessages.messages.has(message) ); }); } // `deriveStableMessageGroups` preserves the identity of a settled group's // `messages` array across streaming chunks, so caching on that array lets the // message list re-render per chunk without re-running the derivation for // every settled turn (#5094). For string-content turns the saved work is the // reverse/filter/map traversal and its allocations — the regex/trim split // itself is already cached per message by `inlineReasoningCache`; for // array-content turns `extractContentFromMessage` has no lower-level cache, // so this also skips its O(bytes) map/join/trim re-run. Settled group arrays // are treated as immutable everywhere else, so the same reference always // yields the same result. const assistantTurnCopyDataCache = new WeakMap(); export function getAssistantTurnCopyData( messages: Message[], { isStreaming = false }: { isStreaming?: boolean } = {}, ) { if (isStreaming) { return null; } const cached = assistantTurnCopyDataCache.get(messages); if (cached !== undefined) { return cached; } const copyData = [...messages] .reverse() .filter((message) => message.type === "ai") .map((message) => { // extractContentFromMessage never returns null, so fall back to // reasoning on empty text (same rule as getMessageCopyData) — // otherwise a reasoning-only turn loses its copy button entirely. const content = extractContentFromMessage(message); return content.length > 0 ? content : (extractReasoningContentFromMessage(message) ?? ""); }) .find((content) => content.length > 0) ?? null; if (copyData !== null) { assistantTurnCopyDataCache.set(messages, copyData); } return copyData; } export function getMessageCopyData(message: Message) { const content = extractContentFromMessage(message); if (message.type === "human") { return stripUploadedFilesTag(content); } if (content.length > 0) { return content; } return extractReasoningContentFromMessage(message) ?? ""; } export function extractTextFromMessage(message: Message) { if (typeof message.content === "string") { return ( splitInlineReasoningFromAIMessage(message)?.content ?? message.content.trim() ); } if (Array.isArray(message.content)) { return message.content .map((content) => typeof content === "string" ? content : content.type === "text" ? content.text : "", ) .join("\n") .trim(); } return ""; } const THINK_OPEN_TAG = ""; const THINK_CLOSE_TAG = ""; interface InlineReasoningSplit { content: string; reasoning: string | null; } function markdownColumns(prefix: string): number { let column = 0; for (const char of prefix) { column += char === "\t" ? 4 - (column % 4) : 1; } return column; } function skipListFence( content: string, start: number, marker: string, listIndent: number, ): number { let lineStart = start; while (lineStart < content.length) { const newline = content.indexOf("\n", lineStart); const lineEnd = newline === -1 ? content.length : newline; const line = content.slice(lineStart, lineEnd); const whitespace = /^[ \t]*/.exec(line)![0]; const indent = markdownColumns(whitespace); if (line.trim() !== "") { // A fenced block cannot outlive its containing list item, even when // the model has not supplied a closing fence yet. if (indent < listIndent) return lineStart; const closer = /^(`{3,}|~{3,})[ \t]*\r?$/.exec( line.slice(whitespace.length), )?.[1]; if ( indent <= listIndent + 3 && closer?.startsWith(marker[0]!) && closer.length >= marker.length ) { return lineEnd; } } lineStart = lineEnd + 1; } return content.length; } function splitInlineReasoning(content: string): InlineReasoningSplit { if (!content.includes(THINK_OPEN_TAG)) { return { content: content.trim(), reasoning: null }; } const reasoningParts: string[] = []; const contentParts: string[] = []; // Scan code delimiters and reasoning openers in source order. Once inside // real reasoning, jump directly to its closing tag: Markdown in reasoning // must not change how the following answer is parsed. // Thematic-break repetitions already consume trailing whitespace. Do not add // another whitespace repetition after them: near-matches then backtrack quadratically. const tokens = /^ {0,3}(`{3,}|~{3,})|^( {4}|\t)|(\r?\n[ \t]*\r?\n)|^ {0,3}(#{1,6})(?=[ \t]|\r?$)|^ {0,3}((?:(?:=+|-+)[ \t]*|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,})\r?$)|^ {0,3}((?:[-+*]|\d{1,9}[.)])[ \t]+)(?=\S)|`+|/gm; let fence: string | null = null; let inlineDelimiter: string | null = null; let headingEnd: number | null = null; let indentedCodeEnd: number | null = null; let contentStart = 0; let match: RegExpExecArray | null; while ((match = tokens.exec(content)) !== null) { if (headingEnd !== null && match.index >= headingEnd) { inlineDelimiter = null; headingEnd = null; } if (match[4] || match[5] || match[6]) { // Headings, thematic breaks and nonempty lists delimit inline spans // without a blank line. Ordered lists must start at 1 to interrupt. if (fence === null) { if (match[6] && !/^(?:[-+*]|1[.)])/.test(match[6])) { const previousLineStart = content.lastIndexOf("\n", match.index - 2) + 1; if (content.slice(previousLineStart, match.index).trim() !== "") { continue; } } inlineDelimiter = null; if (match[4]) { const newline = content.indexOf("\n", tokens.lastIndex); headingEnd = newline === -1 ? content.length : newline; } if (match[6]) { const newline = content.indexOf("\n", tokens.lastIndex); const lineEnd = newline === -1 ? content.length : newline; const line = content.slice(match.index, lineEnd); const listFence = /^ {0,3}(?:(?:[-+*]|\d{1,9}[.)])[ \t]{1,4})+(`{3,}|~{3,})/.exec( line, ); const marker = listFence?.[1]; if ( listFence && marker && (marker.startsWith("~") || !line.slice(listFence[0].length).includes("`")) ) { const prefix = listFence[0].slice(0, -marker.length); tokens.lastIndex = skipListFence( content, lineEnd + 1, marker, markdownColumns(prefix), ); } } } continue; } if (match[3]) { // Inline spans cannot cross paragraph boundaries, unlike fenced code. if (fence === null) inlineDelimiter = null; continue; } if (match[2]) { // An indented continuation can still close an open inline code span. // Indented code cannot interrupt an existing paragraph either. const previousLineStart = content.lastIndexOf("\n", match.index - 2) + 1; const startsBlock = content.slice(previousLineStart, match.index).trim() === ""; const continuesBlock = indentedCodeEnd !== null && content.slice(indentedCodeEnd, match.index).trim() === ""; if (inlineDelimiter === null && (startsBlock || continuesBlock)) { const newline = content.indexOf("\n", tokens.lastIndex); tokens.lastIndex = newline === -1 ? content.length : newline; indentedCodeEnd = tokens.lastIndex; } else { indentedCodeEnd = null; } continue; } const marker = match[1]; if (marker) { const newline = content.indexOf("\n", tokens.lastIndex); const lineEnd = newline === -1 ? content.length : newline; const lineTail = content.slice(tokens.lastIndex, lineEnd); if (fence !== null) { if ( marker.startsWith(fence.charAt(0)) && marker.length >= fence.length && lineTail.trim() === "" ) { fence = null; } tokens.lastIndex = lineEnd; continue; } // Backtick fence info strings cannot contain backticks. Such a run // may instead open or close an inline code span on this line. if (marker.startsWith("~") || !lineTail.includes("`")) { // Fenced blocks also interrupt paragraphs, including unfinished spans. inlineDelimiter = null; fence = marker; tokens.lastIndex = lineEnd; continue; } } if (fence !== null) { continue; } let delimiter = marker ?? match[0]; if (delimiter.startsWith("`")) { // Backslash escapes apply outside a code span, not within one. let escapeStart = match.index; while (escapeStart > 0 && content[escapeStart - 1] === "\\") { escapeStart--; } if (inlineDelimiter === null && (match.index - escapeStart) % 2 === 1) { // An escape consumes one character, not the whole delimiter run. delimiter = delimiter.slice(1); if (!delimiter) continue; } if (inlineDelimiter === null) { inlineDelimiter = delimiter; } else if (inlineDelimiter === delimiter) { inlineDelimiter = null; } continue; } if (inlineDelimiter !== null || match[0] !== THINK_OPEN_TAG) { continue; } contentParts.push(content.slice(contentStart, match.index)); const reasoningStart = tokens.lastIndex; const close = content.indexOf(THINK_CLOSE_TAG, reasoningStart); const reasoning = content .slice(reasoningStart, close === -1 ? undefined : close) .trim(); if (reasoning) { reasoningParts.push(reasoning); } contentStart = close === -1 ? content.length : close + THINK_CLOSE_TAG.length; tokens.lastIndex = contentStart; } contentParts.push(content.slice(contentStart)); return { content: contentParts.join("").trim(), reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null, }; } // 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; } 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) { if (typeof message.content === "string") { return ( splitInlineReasoningFromAIMessage(message)?.content ?? message.content.trim() ); } if (Array.isArray(message.content)) { return message.content .map((content) => { if (typeof content === "string") { return content; } switch (content.type) { case "text": return content.text; case "image_url": const imageURL = extractURLFromImageURLContent(content.image_url); return `![image](${imageURL})`; default: return ""; } }) .join("\n") .trim(); } return ""; } export function extractReasoningContentFromMessage(message: Message) { if (message.type !== "ai") { return null; } if ( message.additional_kwargs && "reasoning_content" in message.additional_kwargs ) { return message.additional_kwargs.reasoning_content as string | null; } if (Array.isArray(message.content)) { const part = message.content[0]; if (part && typeof part === "object" && "thinking" in part) { return part.thinking as string; } } if (typeof message.content === "string") { return splitInlineReasoningFromAIMessage(message)?.reasoning ?? null; } return null; } export function removeReasoningContentFromMessage(message: Message) { if (message.type !== "ai" || !message.additional_kwargs) { return; } delete message.additional_kwargs.reasoning_content; } export function extractURLFromImageURLContent( content: | string | { url: string; }, ) { if (typeof content === "string") { return content; } return content.url; } export function hasContent(message: Message) { if (typeof message.content === "string") { return ( ( splitInlineReasoningFromAIMessage(message)?.content ?? message.content.trim() ).length > 0 ); } if (Array.isArray(message.content)) { return message.content.length > 0; } return false; } export function hasReasoning(message: Message) { if (message.type !== "ai") { return false; } if (typeof message.additional_kwargs?.reasoning_content === "string") { return message.additional_kwargs.reasoning_content.trim().length > 0; } if (Array.isArray(message.content)) { const part = message.content[0]; // Compatible with the Anthropic gateway return (part as unknown as { type: "thinking" })?.type === "thinking"; } if (typeof message.content === "string") { return ( (splitInlineReasoningFromAIMessage(message)?.reasoning ?? null) !== null ); } return false; } export function hasToolCalls(message: Message) { return ( message.type === "ai" && message.tool_calls && message.tool_calls.length > 0 ); } export function hasPresentFiles(message: Message) { return ( message.type === "ai" && message.tool_calls?.some((toolCall) => toolCall.name === "present_files") ); } /** The latest visible user input or clarification result delimits a run. */ export function findCurrentTurnStartIndex( messages: readonly Message[], ): number { for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]; // Clarification replies are hidden: the result, rather than the last // visible human, separates completed answers from their continuation. if ( message && !isHiddenFromUIMessage(message) && (message.type === "human" || isClarificationToolMessage(message)) ) { return index; } } return -1; } export function isClarificationToolMessage(message: Message) { return message.type === "tool" && message.name === "ask_clarification"; } export function extractPresentFilesFromMessage(message: Message) { if (message.type !== "ai" || !hasPresentFiles(message)) { return []; } const files: string[] = []; for (const toolCall of message.tool_calls ?? []) { if ( toolCall.name === "present_files" && Array.isArray(toolCall.args.filepaths) ) { files.push(...(toolCall.args.filepaths as string[])); } } return files; } export function hasSubagent(message: AIMessage) { for (const toolCall of message.tool_calls ?? []) { if (toolCall.name === "task") { return true; } } return false; } export function findToolCallResult(toolCallId: string, messages: Message[]) { for (const message of messages) { if (message.type === "tool" && message.tool_call_id === toolCallId) { const content = extractTextFromMessage(message); if (content) { return content; } } } return undefined; } 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 ( content.includes("") && stripUploadedFilesTag(content).length === 0 ); } /** * Represents a file stored in message additional_kwargs.files. * Used for optimistic UI (uploading state) and structured file metadata. */ export interface FileInMessage { filename: string; size: number; // bytes path?: string; // virtual path, may not be set during upload status?: "uploading" | "uploaded"; } /** * Strip backend-injected human context tags from message content. * Kept under its historical name because callers use it for uploaded-file * display cleanup. * * Display-only backward compatibility for #4212: ```` is no * longer emitted by the backend and is treated as plain content by the * memory/sanitization pipelines, but threads persisted before #4174 still * carry legacy blocks in their history. This display/export layer keeps * stripping it so old threads render cleanly instead of showing raw XML * with server-side upload paths. * * Accepted tradeoff (review): a live user typing the legacy spelling can * hide their own message text / fabricate file chips — display-only and * self-inflicted, with no backend semantics. Age-gating the legacy * spelling is a possible follow-up if this ever matters. */ export function stripUploadedFilesTag(content: string): string { return content .replace( /<(current_uploads|uploaded_files|slash_skill_activation)>[\s\S]*?<\/\1>/g, "", ) .trim(); } /** * Tag names that backend middlewares wrap around internal payloads before * letting them ride along inside LangGraph message ``content``. * * These markers are *not* user copy — they come from: * * - ``UploadsMiddleware`` → ```` (```` is * the pre-#4174 spelling, still stripped here for display/export only so * legacy history does not leak raw blocks or server paths — see #4212) * - ``SkillActivationMiddleware`` → ```` * - ``DynamicContextMiddleware`` → ```` (carrying * ```` / ```` inside), plus the Phase-2 project * context blocks: ```` (instructions identity) and the * request-scoped ```` shelf index. * * The primary export filter is {@link isHiddenFromUIMessage}. This list is * the defence-in-depth strip for any message that — by middleware bug, * provider quirk, or merge-conflict regression — slips through without * its ``hide_from_ui`` flag set. */ export const INTERNAL_MARKER_TAGS = [ "current_uploads", "uploaded_files", "slash_skill_activation", "system-reminder", "memory", "current_date", "project", "documents", ] as const; // The project context blocks carry attributes (````, // ````), so the opener match tolerates an // attribute span — same shape as the streamdown preprocess regex. const INTERNAL_MARKER_RE = new RegExp( `<(${INTERNAL_MARKER_TAGS.join("|")})(?:\\s[^>]*)?>[\\s\\S]*?`, "g", ); /** * Character ranges that must survive marker stripping: fenced code blocks * (marker-aware, so a shorter or different fence inside a block does not * close it) and 4-space indented code lines — the same protection the render * path applies in ``stripLeakedSystemTags``. ``project`` and ``documents`` * are generic tag names, so a fenced Maven ``pom.xml`` or pasted XML must not * lose its span on export; a marker whose span STARTS inside a protected * range is left alone, while injected blocks (never fenced) keep being * removed even when their content contains a fence. */ function protectedCodeRanges(content: string): Array<[number, number]> { const ranges: Array<[number, number]> = []; let fenceMarker: string | null = null; let fenceStart = 0; let offset = 0; for (const line of content.split("\n")) { const fenceMatch = FENCE_MARKER_RE.exec(line); if (fenceMatch) { const marker = fenceMatch[1]!; if (fenceMarker === null) { fenceMarker = marker; fenceStart = offset; } else if ( marker.startsWith(fenceMarker.charAt(0)) && marker.length >= fenceMarker.length ) { ranges.push([fenceStart, offset + line.length]); fenceMarker = null; } } else if (fenceMarker !== null) { // Inside a fenced block: covered by the open range. } else if (INDENTED_CODE_RE.test(line)) { ranges.push([offset, offset + line.length]); } offset += line.length + 1; } if (fenceMarker !== null) { // Unclosed fence: everything after the opener is code. ranges.push([fenceStart, content.length]); } return ranges; } /** * Strip every known backend-injected marker from message content. * * Intended for the chat export path where a marker leaking through is a * privacy regression. UI render paths should keep using * {@link stripUploadedFilesTag} — they receive ``hide_from_ui`` messages * via a separate filter and the narrower function avoids stripping content * a user might legitimately type into a meta-discussion (e.g. asking the * model about its own ```` system). * * Code-aware like the renderer: markers inside fenced or indented code * blocks are preserved, so a pasted ````/```` snippet in * a code block is not silently deleted from the exported markdown. */ export function stripInternalMarkers(content: string): string { const protectedRanges = protectedCodeRanges(content); if (protectedRanges.length === 0) { return content.replace(INTERNAL_MARKER_RE, "").trim(); } return content .replace(INTERNAL_MARKER_RE, (match: string, ...args: unknown[]) => { const offset = args[args.length - 2] as number; const isProtected = protectedRanges.some( ([start, end]) => offset >= start && offset < end, ); return isProtected ? match : ""; }) .trim(); } // The upload context block renders sizes as human-readable strings // (uploads_middleware.py::_format_file_entry emits " KB" / " MB", // mirroring formatBytes). Convert them back to bytes so the parsed // FileInMessage.size honours its bytes contract and chips re-render at the // original magnitude instead of e.g. treating "177.6 KB" as 177 bytes. function parseHumanReadableSize(raw: string): number { const match = /([\d.]+)\s*(B|KB|MB|GB|TB)?/i.exec(raw.trim()); if (!match) return 0; const value = parseFloat(match[1] ?? ""); if (!Number.isFinite(value)) return 0; const multipliers: Record = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4, }; const unit = (match[2] ?? "B").toUpperCase(); return Math.round(value * (multipliers[unit] ?? 1)); } export function parseUploadedFiles(content: string): FileInMessage[] { // Match the upload context block. is what // UploadsMiddleware emits (#4174); is kept for // display-only backward compatibility with pre-#4174 history (#4212). const uploadedFilesRegex = /<(current_uploads|uploaded_files)>([\s\S]*?)<\/\1>/; // eslint-disable-next-line @typescript-eslint/prefer-regexp-exec const match = content.match(uploadedFilesRegex); if (!match) { return []; } const uploadedFilesContent = match[2]; // Check if it's "No files have been uploaded yet." if (uploadedFilesContent?.includes("No files have been uploaded yet.")) { return []; } // Check if the backend reported no new files were uploaded in this message if (uploadedFilesContent?.includes("(empty)")) { return []; } // Parse file list // Format: - filename (size)\n Path: /path/to/file // The filename itself may contain parentheses (e.g. "photo (1).png"), so // the size group is anchored on the trailing "( )" pair the // backend emits instead of stopping the filename at the first "(". const fileRegex = /- (.+)\s*\(([\d.]+\s*(?:B|KB|MB|GB|TB))\)\s*\n\s*Path:\s*([^\n]+)/gi; const files: FileInMessage[] = []; let fileMatch; while ((fileMatch = fileRegex.exec(uploadedFilesContent ?? "")) !== null) { files.push({ filename: fileMatch[1].trim(), size: parseHumanReadableSize(fileMatch[2]), path: fileMatch[3].trim(), }); } return files; }