Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 21 additions & 40 deletions src/browser/components/Messages/UserMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,56 +141,37 @@ export const UserMessage: React.FC<UserMessageProps> = ({
</span>
) : null;
const syntheticClassName = cn(className, isSynthetic && "opacity-70");
const truncationReminder = message.hiddenCountBeforeUser != null && (
<div
data-testid="hidden-gap-reminder"
className="text-muted my-2 flex items-center gap-2 text-xs"
>
<div className="border-border flex-1 border-b border-dashed" />
<span>
{message.hiddenCountBeforeUser} message{message.hiddenCountBeforeUser !== 1 ? "s" : ""}{" "}
hidden
</span>
<div className="border-border flex-1 border-b border-dashed" />
</div>
);

if (isLocalCommandOutput) {
return (
<>
{truncationReminder}
<MessageWindow
label={label}
message={message}
buttons={buttons}
className={syntheticClassName}
variant="user"
>
<TerminalOutput output={extractedOutput} isError={false} />
</MessageWindow>
</>
);
}

return (
<>
{truncationReminder}
<MessageWindow
label={label}
message={message}
buttons={buttons}
className={syntheticClassName}
variant="user"
>
<UserMessageContent
content={content}
commandPrefix={message.commandPrefix}
agentSkillSnapshot={message.agentSkill?.snapshot}
reviews={message.reviews}
fileParts={message.fileParts}
variant="sent"
/>
<TerminalOutput output={extractedOutput} isError={false} />
</MessageWindow>
</>
);
}

return (
<MessageWindow
label={label}
message={message}
buttons={buttons}
className={syntheticClassName}
variant="user"
>
<UserMessageContent
content={content}
commandPrefix={message.commandPrefix}
agentSkillSnapshot={message.agentSkill?.snapshot}
reviews={message.reviews}
fileParts={message.fileParts}
variant="sent"
/>
</MessageWindow>
);
};
95 changes: 44 additions & 51 deletions src/browser/utils/messages/StreamingMessageAggregator.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<DisplayedMessage, { type: "history-hidden" }> => {
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");
Expand All @@ -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] = [];
Expand All @@ -374,40 +378,37 @@ 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<DisplayedMessage, { type: "history-hidden" }> => {
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<DisplayedMessage, { type: "user" }> => {
return msg.type === "user";
}
);
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(
[
Expand All @@ -419,20 +420,12 @@ describe("StreamingMessageAggregator", () => {
);

const displayed = aggregator.getDisplayedMessages();
const userMessages = displayed.filter(
(msg): msg is Extract<DisplayedMessage, { type: "user" }> => {
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,
Expand Down
97 changes: 13 additions & 84 deletions src/browser/utils/messages/StreamingMessageAggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -82,7 +83,7 @@ type AgentStatus = z.infer<typeof AgentStatusSchema>;
/**
* 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;

Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions src/browser/utils/messages/messageUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
20 changes: 18 additions & 2 deletions src/browser/utils/messages/messageUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand All @@ -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);
}

Expand Down
Loading
Loading