From bc697107b162a6bf35728e5cfd0d9668f61a0cd7 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 18 Feb 2026 13:11:34 -0600 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20fix:=20make=20transcript=20omiss?= =?UTF-8?q?ion=20markers=20explicit=20and=20reliable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor truncation rendering to use a pure omission-segment planner, remove per-user hidden gap reminders, and harden deferred snapshot stale detection by row identity/order. Also updates truncation and message-utils tests for capped multi-segment history-hidden behavior and adds dedicated planner coverage. --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$0.00`_ --- .../components/Messages/UserMessage.tsx | 61 ++--- .../StreamingMessageAggregator.test.ts | 95 ++++---- .../messages/StreamingMessageAggregator.ts | 97 ++------ .../utils/messages/messageUtils.test.ts | 14 ++ src/browser/utils/messages/messageUtils.ts | 20 +- .../messages/transcriptTruncationPlan.test.ts | 163 +++++++++++++ .../messages/transcriptTruncationPlan.ts | 219 ++++++++++++++++++ src/common/types/message.ts | 2 - tests/ui/chat/truncation.test.ts | 40 ++-- 9 files changed, 516 insertions(+), 195 deletions(-) create mode 100644 src/browser/utils/messages/transcriptTruncationPlan.test.ts create mode 100644 src/browser/utils/messages/transcriptTruncationPlan.ts diff --git a/src/browser/components/Messages/UserMessage.tsx b/src/browser/components/Messages/UserMessage.tsx index 86cd451b02..b62a285a41 100644 --- a/src/browser/components/Messages/UserMessage.tsx +++ b/src/browser/components/Messages/UserMessage.tsx @@ -141,40 +141,9 @@ export const UserMessage: React.FC = ({ ) : null; const syntheticClassName = cn(className, isSynthetic && "opacity-70"); - const truncationReminder = message.hiddenCountBeforeUser != null && ( -
-
- - {message.hiddenCountBeforeUser} message{message.hiddenCountBeforeUser !== 1 ? "s" : ""}{" "} - hidden - -
-
- ); if (isLocalCommandOutput) { return ( - <> - {truncationReminder} - - - - - ); - } - - return ( - <> - {truncationReminder} = ({ className={syntheticClassName} variant="user" > - + - + ); + } + + return ( + + + ); }; diff --git a/src/browser/utils/messages/StreamingMessageAggregator.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.test.ts index 35a40ffe5a..32a5651e81 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from "bun:test"; import { createMuxMessage, type DisplayedMessage } from "@/common/types/message"; +import { MAX_HISTORY_HIDDEN_SEGMENTS } from "./transcriptTruncationPlan"; import { StreamingMessageAggregator } from "./StreamingMessageAggregator"; // Test helper: create aggregator with default createdAt for tests @@ -315,20 +316,23 @@ describe("StreamingMessageAggregator", () => { // In those 336: user messages are kept, while assistant/tool/reasoning may be omitted. const capped = aggregator.getDisplayedMessages(); - // Verify a single history-hidden marker is present (some tool calls were filtered) - const hiddenMessages = capped.filter((msg) => msg.type === "history-hidden"); - expect(hiddenMessages).toHaveLength(1); - const hiddenIndex = capped.findIndex((msg) => msg.type === "history-hidden"); - expect(hiddenIndex).toBeGreaterThan(0); - expect(hiddenIndex).toBeLessThan(capped.length - 1); - if (hiddenIndex !== -1) { - expect(capped[hiddenIndex - 1]?.type).toBe("user"); - expect(capped[hiddenIndex + 1]?.type).toBe("user"); - expect(capped[hiddenIndex]?.type).toBe("history-hidden"); - if (capped[hiddenIndex]?.type === "history-hidden") { - expect(capped[hiddenIndex].hiddenCount).toBeGreaterThan(0); + const expectedHiddenCount = 252; + const hiddenMessages = capped.filter( + (msg): msg is Extract => { + return msg.type === "history-hidden"; } - } + ); + expect(hiddenMessages).toHaveLength(MAX_HISTORY_HIDDEN_SEGMENTS); + expect(hiddenMessages.every((msg) => msg.hiddenCount > 0)).toBe(true); + expect(hiddenMessages.reduce((sum, msg) => sum + msg.hiddenCount, 0)).toBe( + expectedHiddenCount + ); + + const firstHiddenIndex = capped.findIndex((msg) => msg.type === "history-hidden"); + expect(firstHiddenIndex).toBeGreaterThan(0); + expect(firstHiddenIndex).toBeLessThan(capped.length - 1); + expect(capped[firstHiddenIndex - 1]?.type).toBe("user"); + expect(capped[firstHiddenIndex + 1]?.type).toBe("user"); // User prompts remain fully visible; older assistant rows can be omitted. const userMessages = capped.filter((m) => m.type === "user"); @@ -346,9 +350,9 @@ describe("StreamingMessageAggregator", () => { expect(displayed.some((m) => m.type === "history-hidden")).toBe(false); }); - test("should collapse alternating user/assistant history behind a single history-hidden marker", () => { - // Regression test: alternating user/assistant history previously produced one marker per - // omitted assistant gap, which could recreate near-full DOM row counts. + test("should cap history-hidden markers for alternating user/assistant history", () => { + // Alternating user/assistant history creates many tiny omission runs. + // We preserve locality for recent runs while capping marker rows to keep DOM size bounded. const manyMessages: Parameters< typeof StreamingMessageAggregator.prototype.loadHistoricalMessages >[0] = []; @@ -374,15 +378,24 @@ describe("StreamingMessageAggregator", () => { aggregator.loadHistoricalMessages(manyMessages, false); const displayed = aggregator.getDisplayedMessages(); - const hiddenMarkers = displayed.filter((msg) => msg.type === "history-hidden"); - expect(hiddenMarkers).toHaveLength(1); - expect(displayed.some((msg) => msg.id.startsWith("history-hidden-gap-"))).toBe(false); + const hiddenMarkers = displayed.filter( + (msg): msg is Extract => { + return msg.type === "history-hidden"; + } + ); - const hiddenIndex = displayed.findIndex((msg) => msg.type === "history-hidden"); - expect(hiddenIndex).toBeGreaterThan(0); - expect(hiddenIndex).toBeLessThan(displayed.length - 1); - expect(displayed[hiddenIndex - 1]?.type).toBe("user"); - expect(displayed[hiddenIndex + 1]?.type).toBe("user"); + expect(hiddenMarkers).toHaveLength(MAX_HISTORY_HIDDEN_SEGMENTS); + expect(hiddenMarkers.reduce((sum, marker) => sum + marker.hiddenCount, 0)).toBe(168); + + const hiddenIndices = displayed + .map((msg, index) => (msg.type === "history-hidden" ? index : -1)) + .filter((index) => index !== -1); + for (const hiddenIndex of hiddenIndices) { + expect(hiddenIndex).toBeGreaterThan(0); + expect(hiddenIndex).toBeLessThan(displayed.length - 1); + expect(displayed[hiddenIndex - 1]?.type).toBe("user"); + expect(displayed[hiddenIndex + 1]?.type).toBe("user"); + } const userMessages = displayed.filter( (msg): msg is Extract => { @@ -390,24 +403,12 @@ describe("StreamingMessageAggregator", () => { } ); expect(userMessages).toHaveLength(200); - expect(userMessages[0]?.hiddenCountBeforeUser).toBeUndefined(); - expect(userMessages[1]?.hiddenCountBeforeUser).toBeUndefined(); - expect(userMessages[2]?.hiddenCountBeforeUser).toBe(1); - expect(userMessages[167]?.hiddenCountBeforeUser).toBe(1); - expect(userMessages[168]?.hiddenCountBeforeUser).toBe(1); - expect(userMessages[169]?.hiddenCountBeforeUser).toBeUndefined(); - - const gapReminderCounts = userMessages - .map((message) => message.hiddenCountBeforeUser) - .filter((count): count is number => count != null); - expect(gapReminderCounts).toHaveLength(167); - expect(gapReminderCounts.every((count) => count === 1)).toBe(true); - - // With a single marker, rendered rows stay significantly below full history length. - expect(displayed.length).toBeLessThan(300); + + // Rendered rows stay well below full history size because hidden markers are capped. + expect(displayed.length).toBeLessThan(260); }); - test("should not set hiddenCountBeforeUser for consecutive user messages without truncation", () => { + test("should not show history-hidden when messages are below truncation threshold", () => { const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT); aggregator.loadHistoricalMessages( [ @@ -419,20 +420,12 @@ describe("StreamingMessageAggregator", () => { ); const displayed = aggregator.getDisplayedMessages(); - const userMessages = displayed.filter( - (msg): msg is Extract => { - return msg.type === "user"; - } - ); - - expect(userMessages).toHaveLength(3); - for (const message of userMessages) { - expect(message.hiddenCountBeforeUser).toBeUndefined(); - } + expect(displayed).toHaveLength(3); + expect(displayed.some((msg) => msg.type === "history-hidden")).toBe(false); }); test("should not show history-hidden when only user messages exceed cap", () => { - // When all messages are user/assistant (always-keep types), no filtering occurs + // When all messages are user rows (always-keep type), no filtering occurs const manyMessages = Array.from({ length: 200 }, (_, i) => createMuxMessage(`u${i}`, "user", `msg-${i}`, { timestamp: i, diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 4995c121d1..d4aaf79dd4 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -44,6 +44,7 @@ import { INIT_HOOK_MAX_LINES } from "@/common/constants/toolLimits"; import { isDynamicToolPart } from "@/common/types/toolParts"; import { z } from "zod"; import { createDeltaStorage, type DeltaRecordStorage } from "./StreamingTPSCalculator"; +import { buildTranscriptTruncationPlan } from "./transcriptTruncationPlan"; import { computeRecencyTimestamp } from "./recency"; import { assert } from "@/common/utils/assert"; import { getStatusStateKey } from "@/common/constants/storage"; @@ -82,7 +83,7 @@ type AgentStatus = z.infer; /** * Maximum number of DisplayedMessages to render before truncation kicks in. * We keep all user prompts and structural markers, while allowing older assistant - * content to collapse behind the history-hidden marker for faster initial paint. + * content to collapse behind history-hidden markers for faster initial paint. */ const MAX_DISPLAYED_MESSAGES = 64; @@ -2676,92 +2677,20 @@ export class StreamingMessageAggregator { let resultMessages = displayedMessages; // Limit messages for DOM performance (unless explicitly disabled). - // Strategy: keep user prompts + structural markers while allowing older assistant/tool/ - // reasoning rows to collapse behind a history-hidden marker. + // Strategy: keep recent rows intact, preserve structural rows in older history, + // and materialize omission runs as explicit history-hidden marker rows. // Full history is still maintained internally for token counting. if (!this.showAllMessages && displayedMessages.length > MAX_DISPLAYED_MESSAGES) { - // Split into "old" (candidates for filtering) and "recent" (always keep intact) - const recentMessages = displayedMessages.slice(-MAX_DISPLAYED_MESSAGES); - const oldMessages = displayedMessages.slice(0, -MAX_DISPLAYED_MESSAGES); - - const omittedMessageCounts = { tool: 0, reasoning: 0 }; - let hiddenCount = 0; - let insertionIndex: number | null = null; - let pendingOmittedCount = 0; - let consumedFirstOmittedRun = false; - const filteredOldMessages: DisplayedMessage[] = []; - - const addHiddenGapReminderToNextUser = ( - messages: DisplayedMessage[], - hiddenCountBeforeUser: number - ): DisplayedMessage[] => { - const nextUserIndex = messages.findIndex((message) => message.type === "user"); - if (nextUserIndex === -1) { - return messages; - } - - const nextUser = messages[nextUserIndex]; - if (nextUser?.type !== "user") { - return messages; - } - - const messagesWithReminder = [...messages]; - messagesWithReminder[nextUserIndex] = { ...nextUser, hiddenCountBeforeUser }; - return messagesWithReminder; - }; - - for (const msg of oldMessages) { - if (!ALWAYS_KEEP_MESSAGE_TYPES.has(msg.type)) { - if (msg.type === "tool") { - omittedMessageCounts.tool += 1; - } else if (msg.type === "reasoning") { - omittedMessageCounts.reasoning += 1; - } - - hiddenCount += 1; - pendingOmittedCount += 1; - insertionIndex ??= filteredOldMessages.length; - continue; - } - - // Stamp per-gap count on user rows after the first omitted run. - if (msg.type === "user" && pendingOmittedCount > 0 && consumedFirstOmittedRun) { - filteredOldMessages.push({ ...msg, hiddenCountBeforeUser: pendingOmittedCount }); - } else { - filteredOldMessages.push(msg); - } - - if (pendingOmittedCount > 0) { - consumedFirstOmittedRun = true; - pendingOmittedCount = 0; - } - } - - const recentMessagesWithGapReminders = - pendingOmittedCount > 0 && consumedFirstOmittedRun - ? addHiddenGapReminderToNextUser(recentMessages, pendingOmittedCount) - : recentMessages; - - const hasOmissions = hiddenCount > 0; - - if (hasOmissions) { - const insertAt = insertionIndex ?? filteredOldMessages.length; - const messagesWithMarker = [...filteredOldMessages]; - messagesWithMarker.splice(insertAt, 0, { - type: "history-hidden" as const, - id: "history-hidden", - hiddenCount, - historySequence: -1, - omittedMessageCounts, - }); + const truncationPlan = buildTranscriptTruncationPlan({ + displayedMessages, + maxDisplayedMessages: MAX_DISPLAYED_MESSAGES, + alwaysKeepMessageTypes: ALWAYS_KEEP_MESSAGE_TYPES, + }); - resultMessages = this.normalizeLastPartFlags([ - ...messagesWithMarker, - ...recentMessagesWithGapReminders, - ]); - } else { - resultMessages = [...filteredOldMessages, ...recentMessagesWithGapReminders]; - } + resultMessages = + truncationPlan.hiddenCount > 0 + ? this.normalizeLastPartFlags(truncationPlan.rows) + : truncationPlan.rows; } // Add init state if present (ephemeral, appears at top) diff --git a/src/browser/utils/messages/messageUtils.test.ts b/src/browser/utils/messages/messageUtils.test.ts index 1e3dd89141..adb754c52e 100644 --- a/src/browser/utils/messages/messageUtils.test.ts +++ b/src/browser/utils/messages/messageUtils.test.ts @@ -91,6 +91,20 @@ describe("shouldBypassDeferredMessages", () => { expect(shouldBypassDeferredMessages([completedBash], [])).toBe(true); }); + it("returns true when snapshots have same length but different row identity/order", () => { + const userRow: DisplayedMessage = { + type: "user", + id: "u-1", + historyId: "h-user", + content: "hello", + historySequence: 0, + }; + + expect(shouldBypassDeferredMessages([completedBash, userRow], [userRow, completedBash])).toBe( + true + ); + }); + it("returns false when both snapshots are settled and in sync", () => { expect(shouldBypassDeferredMessages([completedBash], [completedBash])).toBe(false); }); diff --git a/src/browser/utils/messages/messageUtils.ts b/src/browser/utils/messages/messageUtils.ts index fd81ed707b..ab9c88e7e5 100644 --- a/src/browser/utils/messages/messageUtils.ts +++ b/src/browser/utils/messages/messageUtils.ts @@ -118,8 +118,9 @@ export function isStreamingPart(part: unknown): part is { type: "text"; state: " * any tool call is still executing (e.g. live bash output). * * We also bypass when the deferred snapshot appears stale (it still has active - * streaming/executing rows after the immediate snapshot is idle), since showing - * stale deferred tool state can cause output/layout flash at stream completion. + * streaming/executing rows after the immediate snapshot is idle), or when both + * snapshots have diverged in row identity/order. Showing stale deferred rows can + * cause hidden-marker placement and tool-state flash at stream completion. */ export function shouldBypassDeferredMessages( messages: DisplayedMessage[], @@ -135,6 +136,21 @@ export function shouldBypassDeferredMessages( return true; } + for (let i = 0; i < messages.length; i++) { + const immediateMessage = messages[i]; + const deferredMessage = deferredMessages[i]; + if (!immediateMessage || !deferredMessage) { + return true; + } + + if ( + immediateMessage.id !== deferredMessage.id || + immediateMessage.type !== deferredMessage.type + ) { + return true; + } + } + return hasActiveRows(messages) || hasActiveRows(deferredMessages); } diff --git a/src/browser/utils/messages/transcriptTruncationPlan.test.ts b/src/browser/utils/messages/transcriptTruncationPlan.test.ts new file mode 100644 index 0000000000..3facf942a2 --- /dev/null +++ b/src/browser/utils/messages/transcriptTruncationPlan.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from "bun:test"; +import type { DisplayedMessage } from "@/common/types/message"; +import { + buildTranscriptTruncationPlan, + MAX_HISTORY_HIDDEN_SEGMENTS, +} from "./transcriptTruncationPlan"; + +const ALWAYS_KEEP_MESSAGE_TYPES = new Set([ + "user", + "stream-error", + "compaction-boundary", + "plan-display", + "workspace-init", +]); + +function user(id: string, sequence: number): DisplayedMessage { + return { + type: "user", + id, + historyId: `history-${id}`, + content: id, + historySequence: sequence, + }; +} + +function assistant(id: string, sequence: number): DisplayedMessage { + return { + type: "assistant", + id, + historyId: `history-${id}`, + content: id, + historySequence: sequence, + isStreaming: false, + isPartial: false, + isCompacted: false, + isIdleCompacted: false, + }; +} + +function tool(id: string, sequence: number): DisplayedMessage { + return { + type: "tool", + id, + historyId: `history-${id}`, + toolCallId: `call-${id}`, + toolName: "bash", + args: { script: "echo hi", timeout_secs: 1, display_name: "test" }, + status: "completed", + isPartial: false, + historySequence: sequence, + }; +} + +function reasoning(id: string, sequence: number): DisplayedMessage { + return { + type: "reasoning", + id, + historyId: `history-${id}`, + content: id, + historySequence: sequence, + isStreaming: false, + isPartial: false, + }; +} + +function compactionBoundary( + id: string, + sequence: number, + position: "start" | "end" = "start" +): DisplayedMessage { + return { + type: "compaction-boundary", + id, + historySequence: sequence, + position, + }; +} + +describe("buildTranscriptTruncationPlan", () => { + test("places an omission marker directly before a kept non-user seam", () => { + const displayedMessages: DisplayedMessage[] = [ + user("u0", 0), + assistant("a0", 1), + user("u1", 2), + assistant("a1", 3), + compactionBoundary("boundary-1", 4, "start"), + user("u2", 5), + assistant("a2", 6), + user("u3", 7), + assistant("a3", 8), + user("u4", 9), + ]; + + const plan = buildTranscriptTruncationPlan({ + displayedMessages, + maxDisplayedMessages: 4, + alwaysKeepMessageTypes: ALWAYS_KEEP_MESSAGE_TYPES, + }); + + const markerIndices = plan.rows + .map((message, index) => (message.type === "history-hidden" ? index : -1)) + .filter((index) => index !== -1); + + expect(markerIndices).toHaveLength(2); + const secondMarkerIndex = markerIndices[1]; + expect(plan.rows[secondMarkerIndex + 1]?.type).toBe("compaction-boundary"); + }); + + test("keeps a trailing omission marker at the old/recent seam when no later user exists", () => { + const displayedMessages: DisplayedMessage[] = [ + user("u0", 0), + assistant("a0", 1), + user("u1", 2), + assistant("a1", 3), + assistant("a2", 4), + tool("tool-1", 5), + reasoning("reasoning-1", 6), + assistant("a3", 7), + ]; + + const plan = buildTranscriptTruncationPlan({ + displayedMessages, + maxDisplayedMessages: 4, + alwaysKeepMessageTypes: ALWAYS_KEEP_MESSAGE_TYPES, + }); + + const markerIndices = plan.rows + .map((message, index) => (message.type === "history-hidden" ? index : -1)) + .filter((index) => index !== -1); + + expect(markerIndices).toHaveLength(2); + const trailingMarkerIndex = markerIndices[1]; + expect(plan.rows[trailingMarkerIndex + 1]?.id).toBe("a2"); + }); + + test("caps omission markers by merging older runs", () => { + const displayedMessages: DisplayedMessage[] = []; + for (let i = 0; i < 20; i++) { + displayedMessages.push(user(`u${i}`, i * 2)); + displayedMessages.push(assistant(`a${i}`, i * 2 + 1)); + } + + const plan = buildTranscriptTruncationPlan({ + displayedMessages, + maxDisplayedMessages: 4, + alwaysKeepMessageTypes: ALWAYS_KEEP_MESSAGE_TYPES, + maxHiddenSegments: 3, + }); + + expect(MAX_HISTORY_HIDDEN_SEGMENTS).toBeGreaterThan(3); + expect(plan.segments).toHaveLength(3); + expect(plan.hiddenCount).toBe(18); + expect(plan.segments.map((segment) => segment.hiddenCount)).toEqual([16, 1, 1]); + + const markerRows = plan.rows.filter( + (message): message is Extract => { + return message.type === "history-hidden"; + } + ); + expect(markerRows).toHaveLength(3); + expect(markerRows.map((row) => row.hiddenCount)).toEqual([16, 1, 1]); + }); +}); diff --git a/src/browser/utils/messages/transcriptTruncationPlan.ts b/src/browser/utils/messages/transcriptTruncationPlan.ts new file mode 100644 index 0000000000..a5e59dc8cd --- /dev/null +++ b/src/browser/utils/messages/transcriptTruncationPlan.ts @@ -0,0 +1,219 @@ +import type { DisplayedMessage } from "@/common/types/message"; + +/** + * Upper bound for distinct hidden-gap rows. + * + * In transcripts with many tiny omission runs (e.g. alternating user/assistant history), + * rendering one marker per run can recreate large DOM row counts. We keep locality for + * recent runs while merging older runs into a single earlier marker when this cap is exceeded. + */ +export const MAX_HISTORY_HIDDEN_SEGMENTS = 8; + +interface OmittedMessageCounts { + tool: number; + reasoning: number; +} + +export interface OmissionSegment { + insertAtKeptIndex: number; + hiddenCount: number; + omittedMessageCounts: OmittedMessageCounts; +} + +export interface TranscriptTruncationPlan { + rows: DisplayedMessage[]; + hiddenCount: number; + segments: OmissionSegment[]; +} + +export interface BuildTranscriptTruncationPlanArgs { + displayedMessages: DisplayedMessage[]; + maxDisplayedMessages: number; + alwaysKeepMessageTypes: Set; + maxHiddenSegments?: number; +} + +interface CollectedOmissions { + keptOldMessages: DisplayedMessage[]; + segments: OmissionSegment[]; + hiddenCount: number; +} + +interface OmissionRunState { + insertAtKeptIndex: number; + hiddenCount: number; + omittedMessageCounts: OmittedMessageCounts; +} + +function collectOmissions( + oldMessages: DisplayedMessage[], + alwaysKeepMessageTypes: Set +): CollectedOmissions { + const keptOldMessages: DisplayedMessage[] = []; + const segments: OmissionSegment[] = []; + let hiddenCount = 0; + let activeRun: OmissionRunState | null = null; + + for (const message of oldMessages) { + if (alwaysKeepMessageTypes.has(message.type)) { + if (activeRun !== null) { + segments.push(activeRun); + activeRun = null; + } + keptOldMessages.push(message); + continue; + } + + activeRun ??= { + insertAtKeptIndex: keptOldMessages.length, + hiddenCount: 0, + omittedMessageCounts: { tool: 0, reasoning: 0 }, + }; + + activeRun.hiddenCount += 1; + hiddenCount += 1; + + if (message.type === "tool") { + activeRun.omittedMessageCounts.tool += 1; + } else if (message.type === "reasoning") { + activeRun.omittedMessageCounts.reasoning += 1; + } + } + + if (activeRun !== null) { + segments.push(activeRun); + } + + return { keptOldMessages, segments, hiddenCount }; +} + +function mergeOmissionSegments(segments: OmissionSegment[]): OmissionSegment { + const first = segments[0]; + let hiddenCount = 0; + let toolCount = 0; + let reasoningCount = 0; + + for (const segment of segments) { + hiddenCount += segment.hiddenCount; + toolCount += segment.omittedMessageCounts.tool; + reasoningCount += segment.omittedMessageCounts.reasoning; + } + + return { + insertAtKeptIndex: first?.insertAtKeptIndex ?? 0, + hiddenCount, + omittedMessageCounts: { + tool: toolCount, + reasoning: reasoningCount, + }, + }; +} + +function capOmissionSegments( + segments: OmissionSegment[], + maxHiddenSegments: number +): OmissionSegment[] { + const normalizedMax = Number.isFinite(maxHiddenSegments) + ? Math.max(1, Math.trunc(maxHiddenSegments)) + : 1; + if (segments.length <= normalizedMax) { + return segments; + } + + const mergedPrefixCount = segments.length - normalizedMax + 1; + const mergedPrefix = mergeOmissionSegments(segments.slice(0, mergedPrefixCount)); + return [mergedPrefix, ...segments.slice(mergedPrefixCount)]; +} + +function renderKeptRowsWithMarkers( + keptOldMessages: DisplayedMessage[], + segments: OmissionSegment[] +): DisplayedMessage[] { + if (segments.length === 0) { + return [...keptOldMessages]; + } + + const rows: DisplayedMessage[] = []; + let segmentIndex = 0; + + for (let keptIndex = 0; keptIndex <= keptOldMessages.length; keptIndex++) { + while ( + segmentIndex < segments.length && + segments[segmentIndex]?.insertAtKeptIndex === keptIndex + ) { + const segment = segments[segmentIndex]; + const omittedMessageCounts = + segment.omittedMessageCounts.tool > 0 || segment.omittedMessageCounts.reasoning > 0 + ? segment.omittedMessageCounts + : undefined; + + rows.push({ + type: "history-hidden", + id: `history-hidden-${segmentIndex + 1}`, + hiddenCount: segment.hiddenCount, + historySequence: -1, + omittedMessageCounts, + }); + + segmentIndex += 1; + } + + if (keptIndex < keptOldMessages.length) { + rows.push(keptOldMessages[keptIndex]); + } + } + + return rows; +} + +/** + * Build displayed transcript rows for truncated history. + * + * Strategy: + * - Keep recent rows intact (last N rows) + * - In older rows, preserve only always-keep message types + * - Replace each omitted run with an explicit history-hidden marker row + * - Cap marker count to avoid re-creating huge DOM row counts for tiny alternating runs + */ +export function buildTranscriptTruncationPlan( + args: BuildTranscriptTruncationPlanArgs +): TranscriptTruncationPlan { + if ( + args.maxDisplayedMessages <= 0 || + args.displayedMessages.length <= args.maxDisplayedMessages + ) { + return { + rows: args.displayedMessages, + hiddenCount: 0, + segments: [], + }; + } + + const recentMessages = args.displayedMessages.slice(-args.maxDisplayedMessages); + const oldMessages = args.displayedMessages.slice(0, -args.maxDisplayedMessages); + + const omissionCollection = collectOmissions(oldMessages, args.alwaysKeepMessageTypes); + if (omissionCollection.hiddenCount === 0) { + return { + rows: [...omissionCollection.keptOldMessages, ...recentMessages], + hiddenCount: 0, + segments: [], + }; + } + + const cappedSegments = capOmissionSegments( + omissionCollection.segments, + args.maxHiddenSegments ?? MAX_HISTORY_HIDDEN_SEGMENTS + ); + + const rows = [ + ...renderKeptRowsWithMarkers(omissionCollection.keptOldMessages, cappedSegments), + ...recentMessages, + ]; + + return { + rows, + hiddenCount: omissionCollection.hiddenCount, + segments: cappedSegments, + }; +} diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 10e1586371..91fcc841a6 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -489,8 +489,6 @@ export type DisplayedMessage = */ commandPrefix?: string; fileParts?: FilePart[]; // Optional attachments - /** Number of messages omitted by truncation immediately before this user message. */ - hiddenCountBeforeUser?: number; historySequence: number; // Global ordering across all messages isSynthetic?: boolean; timestamp?: number; diff --git a/tests/ui/chat/truncation.test.ts b/tests/ui/chat/truncation.test.ts index 74e9301ebc..3fd77bb734 100644 --- a/tests/ui/chat/truncation.test.ts +++ b/tests/ui/chat/truncation.test.ts @@ -10,6 +10,7 @@ import { createTestEnvironment, cleanupTestEnvironment, preloadTestModules } fro import { cleanupTempGitRepo, createTempGitRepo, generateBranchName } from "../../ipc/helpers"; import { detectDefaultTrunkBranch } from "@/node/git"; import { HistoryService } from "@/node/services/historyService"; +import { MAX_HISTORY_HIDDEN_SEGMENTS } from "@/browser/utils/messages/transcriptTruncationPlan"; import { createMuxMessage } from "@/common/types/message"; import { installDom } from "../dom"; @@ -112,19 +113,31 @@ describe("Chat truncation UI", () => { const oldPairs = oldDisplayedMessages / 4; const expectedHiddenCount = oldPairs * 3; - const indicator = await waitFor(() => { - const node = view?.getByText(/some messages are hidden for performance/i); - if (!node) { + const indicators = await waitFor(() => { + const nodes = Array.from( + view?.container.querySelectorAll('[data-testid="chat-message"]') ?? [] + ).filter((node) => node.textContent?.match(/some messages are hidden for performance/i)); + if (nodes.length === 0) { throw new Error("Truncation indicator not found"); } - return node; + return nodes; }); - expect(indicator.textContent).toContain("Some messages are hidden for performance"); - expect(indicator.textContent).toContain(`${expectedHiddenCount} messages hidden`); - expect(indicator.textContent).toContain(`${oldPairs} tool calls`); - expect(indicator.textContent).toContain(`${oldPairs} thinking blocks`); - expect(view.getByRole("button", { name: /load all/i })).toBeTruthy(); + expect(indicators).toHaveLength(MAX_HISTORY_HIDDEN_SEGMENTS); + + const sumIndicatorCounts = (pattern: RegExp): number => { + return indicators.reduce((sum, node) => { + const match = node.textContent?.match(pattern); + return sum + (match ? Number(match[1]) : 0); + }, 0); + }; + + expect(sumIndicatorCounts(/(\d+)\s+messages? hidden/i)).toBe(expectedHiddenCount); + expect(sumIndicatorCounts(/(\d+)\s+tool call/i)).toBe(oldPairs); + expect(sumIndicatorCounts(/(\d+)\s+thinking block/i)).toBe(oldPairs); + expect(view.getAllByRole("button", { name: /load all/i })).toHaveLength( + MAX_HISTORY_HIDDEN_SEGMENTS + ); const messageBlocks = Array.from( view.container.querySelectorAll('[data-testid="chat-message"]') @@ -132,13 +145,13 @@ describe("Chat truncation UI", () => { const hiddenIndicatorCount = messageBlocks.filter((node) => node.textContent?.match(/some messages are hidden for performance/i) ).length; - expect(hiddenIndicatorCount).toBe(1); + expect(hiddenIndicatorCount).toBe(MAX_HISTORY_HIDDEN_SEGMENTS); const indicatorIndex = messageBlocks.findIndex((node) => node.textContent?.match(/some messages are hidden for performance/i) ); expect(indicatorIndex).toBeGreaterThan(0); expect(messageBlocks[indicatorIndex - 1]?.textContent).toContain("user-0"); - // After the indicator, the next kept old message is user-1 (assistant rows are now omitted) + // The earliest marker still appears at the first omission seam. expect(messageBlocks[indicatorIndex + 1]?.textContent).toContain("user-1"); // Verify assistant meta rows survive in the recent (non-truncated) section. @@ -148,11 +161,6 @@ describe("Chat truncation UI", () => { const messageBlock = assistantText.closest("[data-message-block]"); expect(messageBlock).toBeTruthy(); expect(messageBlock?.querySelector("[data-message-meta]")).not.toBeNull(); - const gapReminders = view.container.querySelectorAll('[data-testid="hidden-gap-reminder"]'); - expect(gapReminders.length).toBeGreaterThan(0); - for (const reminder of gapReminders) { - expect(reminder.textContent).toMatch(/\d+ messages? hidden/); - } } finally { if (view) { await cleanupView(view, cleanupDom);