/** * Message consolidation — shared logic that maps the raw DB row sequence * onto the displayed transcript. * * A single "display turn" in the UI can span multiple DB rows. During * streaming the agent loop persists each API call as a separate * assistant row (deferred consolidation, for prefix-cache stability), * and intervening user rows often carry only tool_result blocks that * are suppressed at display time. The two-pass collapse here is what * turns the raw sequence into the rows the user actually sees. * * Pass 1 — `mergeToolResultsIntoAssistantMessages` * For each user row, lift its `tool_result` (and `web_search_tool_result`) * blocks onto the preceding assistant row. If the user row had * nothing else (only tool_result + system_notice blocks), drop the * row entirely. Otherwise keep its non-tool-result content as a * real user message. * * Pass 2 — `mergeConsecutiveAssistantMessages` * After pass 1 removes tool-result-only user separators, fold runs * of adjacent assistant rows onto the first row of the run * ("anchor"). Anchors keep their id, createdAt, and metadata; * subagent-notification metadata is promoted from later rows when * the anchor lacks it. * * `findDisplayTurnEndIndex` is the primitive any write-path needs when * it has a single DB-row id and wants to know "which DB rows make up * the display turn that this row anchors". It uses the same * `isToolResultOnlyUserMessage` predicate that pass 1 uses to decide * which user rows are suppressed, so the read-path and write-path * agree on cluster boundaries without duplicating the merge code. */ import type { MessageRow } from "../persistence/conversation-crud.js"; import { isStandaloneAssistantMessage } from "../persistence/conversation-crud.js"; import type { ContentBlock } from "../providers/types.js"; import { getLogger } from "../util/logger.js"; const log = getLogger("message-consolidation"); /** * True when an assistant row is a standalone display turn (system card or * provider-error notice) that never merges with adjacent assistant rows in * either direction. Merging one into an adjacent run would let the anchor's * metadata win and drop the `messageKind` marker from the wire (or stamp it * onto a bubble containing real assistant text). */ function isStandaloneAssistantRow(msg: MessageRow): boolean { return isStandaloneAssistantMessage(msg.role, msg.metadata); } // ── Block predicates ──────────────────────────────────────────────── function isToolResultType(type: string): boolean { return type === "tool_result" || type === "web_search_tool_result"; } function isSystemNoticeText(block: ContentBlock): boolean { return ( block.type === "text" && block.text.startsWith("") && block.text.endsWith("") ); } /** * True when a user row's content is exclusively tool_result blocks * (and optional system_notice text). Such rows are suppressed by the * read-path collapse — they exist in the DB to satisfy the LLM's * tool_use ↔ tool_result pairing requirement but are never displayed * to the user. Any write-path that walks DB rows in display order * must treat them as part of the surrounding assistant turn. */ export function isToolResultOnlyUserMessage(msg: MessageRow): boolean { if (msg.role !== "user") { return false; } let sawToolResult = false; for (const block of msg.content) { if (isToolResultType(block.type)) { sawToolResult = true; continue; } if (isSystemNoticeText(block)) { continue; } return false; } return sawToolResult; } // ── Display-turn boundary lookup ──────────────────────────────────── /** * Returns the inclusive DB-row index where the display turn that * contains `messages[startIdx]` ends. * * For non-assistant rows, returns `startIdx` unchanged — only assistant * turns can span multiple rows. * * For assistant rows, advances past any consecutive rows that the * read-path collapse would fold into the same display turn: * - another assistant row → part of the consecutive-assistant run, OR * - a tool-result-only user row → suppressed at display time, sits * between two halves of the same assistant turn. * * Stops at the first real user message (or any non-collapsible row), * end-of-array, or invalid index. * * Mirror of the cluster boundary that `mergeConsecutiveAssistantMessages` * (post pass-1) would produce — without paying the cost of parsing * every content block. */ export function findDisplayTurnEndIndex( messages: MessageRow[], startIdx: number, ): number { if (startIdx < 0 || startIdx >= messages.length) { return startIdx; } if (messages[startIdx]?.role !== "assistant") { return startIdx; } // System cards and provider-error rows are single-row display turns that // never span neighbours. if (isStandaloneAssistantRow(messages[startIdx]!)) { return startIdx; } let endIdx = startIdx; while (endIdx + 1 < messages.length) { const next = messages[endIdx + 1]; if (!next) { break; } if (next.role === "assistant" && !isStandaloneAssistantRow(next)) { endIdx += 1; continue; } if (next.role === "user" && isToolResultOnlyUserMessage(next)) { endIdx += 1; continue; } break; } return endIdx; } // ── Pass 1: tool-result merging ───────────────────────────────────── /** * Merge tool_result blocks from user messages into the preceding assistant * message's content array. This lets renderHistoryContent's pendingToolUses * map pair tool_use and tool_result blocks, preventing "unknown" tool names. * * User messages that consist entirely of tool_result blocks (and optional * system_notice text) are removed from the output. Mixed messages (tool_result * + real user text) keep only the non-tool-result blocks. */ export function mergeToolResultsIntoAssistantMessages( messages: MessageRow[], ): MessageRow[] { // Index of the most recent assistant message in the output array. let lastAssistantIdx = -1; // Parsed content caches — lazily populated per assistant message. const parsedAssistantContent = new Map(); const result: MessageRow[] = []; for (const msg of messages) { if (msg.role === "assistant") { lastAssistantIdx = result.length; result.push(msg); continue; } // Only process user messages — other roles pass through. if (msg.role !== "user") { result.push(msg); continue; } // Separate tool-result blocks from real user content. const toolResultBlocks: ContentBlock[] = []; const otherBlocks: ContentBlock[] = []; for (const block of msg.content) { if (isToolResultType(block.type)) { toolResultBlocks.push(block); } else { otherBlocks.push(block); } } // No tool results → pass through unchanged. System notices are only // injected alongside tool results in the agent loop, so a pure user // message (no tool_result blocks) should never be filtered — even if // the user's text happens to look like a system_notice tag. if (toolResultBlocks.length === 0) { result.push(msg); continue; } // Append tool_result blocks to the preceding assistant message's content. // No-op at pagination boundaries (lastAssistantIdx < 0); orphan tool_results // are silently dropped by renderHistoryContent downstream either way. if (lastAssistantIdx >= 0) { const assistant = result[lastAssistantIdx]; let assistantContent = parsedAssistantContent.get(lastAssistantIdx); if (!assistantContent) { assistantContent = [...assistant.content]; parsedAssistantContent.set(lastAssistantIdx, assistantContent); } assistantContent.push(...toolResultBlocks); } // If the user message had only tool_result (+ system_notice) blocks, // suppress it entirely. Otherwise keep the non-tool-result content. // System notices don't count as real user content — they are only // injected alongside tool results in the agent loop. const realUserContent = otherBlocks.filter((b) => !isSystemNoticeText(b)); if (realUserContent.length > 0) { result.push({ ...msg, content: otherBlocks }); } // else: tool-result-only → suppressed } // Write back any modified assistant message content. for (const [idx, content] of parsedAssistantContent) { result[idx] = { ...result[idx], content }; } return result; } // ── Pass 2: consecutive-assistant merging ─────────────────────────── /** * Promote metadata fields from a donor message to the surviving message * when the survivor lacks them. Currently promotes `subagentNotification`. * Returns a new MessageRow if promotion occurred, otherwise the original. */ function promoteMetadata(survivor: MessageRow, donor: MessageRow): MessageRow { if (donor.metadata && survivor.metadata) { try { const survivorMeta = JSON.parse(survivor.metadata); const donorMeta = JSON.parse(donor.metadata); if ( !survivorMeta.subagentNotification && donorMeta.subagentNotification ) { survivorMeta.subagentNotification = donorMeta.subagentNotification; return { ...survivor, metadata: JSON.stringify(survivorMeta) }; } } catch (err) { log.warn( { err }, "Failed to parse metadata during assistant message merge", ); } } else if (donor.metadata && !survivor.metadata) { return { ...survivor, metadata: donor.metadata }; } return survivor; } /** * Merge consecutive assistant messages into a single message at query time. * * During streaming, all assistant turns within one agent loop accumulate on * a single client-side message. In the DB, each API turn is stored as a * separate assistant row (consolidation is deferred to compaction for * prefix-cache stability). This produces N separate assistant messages that * the client would otherwise render as N individual rows — each showing * "Completed 1 step" instead of one grouped "Completed N steps" accordion. * * This function concatenates the content block arrays of consecutive * assistant messages (no intervening user messages after tool-result * merging) into the first message of each run. The merged messages are * removed from the output. This is query-time only — the DB is not * modified. * * The first message in each run keeps its id, createdAt, and metadata so * that attachment lookups, display timestamps, and subagent notifications * continue to work. Metadata from later messages in the run (e.g. * subagentNotification) is preserved by promoting it to the surviving * message when the surviving message has no metadata of its own for that * field. */ export function mergeConsecutiveAssistantMessages(messages: MessageRow[]): { messages: MessageRow[]; /** Maps each surviving message ID → all original message IDs merged into it. */ mergedIdMap: Map; } { const result: MessageRow[] = []; // Key = index in `result`, value = accumulated content blocks. const pendingMerges = new Map(); // Key = index in `result`, value = IDs of messages merged into the target. const mergedIds = new Map(); for (const msg of messages) { const lastIdx = result.length - 1; // System cards and provider-error rows stay standalone in both // directions: neither folds into the preceding assistant run, and the // following assistant row never folds into them. const isConsecutiveAssistant = msg.role === "assistant" && !isStandaloneAssistantRow(msg) && lastIdx >= 0 && result[lastIdx].role === "assistant" && !isStandaloneAssistantRow(result[lastIdx]); if (!isConsecutiveAssistant) { result.push(msg); continue; } // Track the donor message ID. let ids = mergedIds.get(lastIdx); if (!ids) { ids = []; mergedIds.set(lastIdx, ids); } ids.push(msg.id); // Lazily parse the target's content on first merge. let targetContent = pendingMerges.get(lastIdx); if (!targetContent) { targetContent = [...result[lastIdx].content]; pendingMerges.set(lastIdx, targetContent); } targetContent.push(...msg.content); result[lastIdx] = promoteMetadata(result[lastIdx], msg); } // Write back merged content for any messages that were targets. for (const [idx, content] of pendingMerges) { result[idx] = { ...result[idx], content }; } // Build the merged ID map keyed by surviving message ID. const mergedIdMap = new Map(); for (const [idx, ids] of mergedIds) { mergedIdMap.set(result[idx].id, ids); } return { messages: result, mergedIdMap }; }