Skip to content

Commit 7badb52

Browse files
committed
combine UIMessages across page boundaries
1 parent 25975ff commit 7badb52

File tree

2 files changed

+66
-2
lines changed

2 files changed

+66
-2
lines changed

src/deltas.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import {
1818
type StreamMessage,
1919
} from "./validators.js";
2020

21-
2221
export function blankUIMessage<METADATA = unknown>(
2322
streamMessage: StreamMessage & { metadata?: METADATA },
2423
threadId: string,
@@ -538,3 +537,65 @@ function mergeProviderMetadata(
538537
}
539538
return merged;
540539
}
540+
541+
export function combineUIMessages(messages: UIMessage[]): UIMessage[] {
542+
const combined = messages.reduce((acc, message) => {
543+
if (!acc.length) {
544+
return [message];
545+
}
546+
const previous = acc.at(-1)!;
547+
if (previous.role !== message.role) {
548+
acc.push(message);
549+
return acc;
550+
}
551+
// We will replace it with a combined message
552+
acc.pop();
553+
const newParts = [...previous.parts];
554+
for (const part of message.parts) {
555+
const toolCallId = getToolCallId(part);
556+
if (!toolCallId) {
557+
newParts.push(part);
558+
continue;
559+
}
560+
const previousPartIndex = newParts.findIndex(
561+
(p) => getToolCallId(p) === toolCallId,
562+
);
563+
const previousPart = newParts.splice(previousPartIndex, 1)[0];
564+
if (!previousPart) {
565+
newParts.push(part);
566+
continue;
567+
}
568+
newParts.push(mergeParts(previousPart, part));
569+
}
570+
acc.push({
571+
...previous,
572+
...pick(message, ["status", "metadata", "agentName"]),
573+
parts: newParts,
574+
text: newParts
575+
.filter((p) => p.type === "text")
576+
.map((p) => p.text)
577+
.join(""),
578+
});
579+
return acc;
580+
}, [] as UIMessage[]);
581+
return combined;
582+
}
583+
584+
function getToolCallId(
585+
part: UIMessage["parts"][number] & { toolCallId?: string },
586+
) {
587+
return part.toolCallId;
588+
}
589+
590+
function mergeParts(
591+
previousPart: UIMessage["parts"][number],
592+
part: UIMessage["parts"][number],
593+
): UIMessage["parts"][number] {
594+
const merged: Record<string, unknown> = { ...previousPart };
595+
for (const [key, value] of Object.entries(part)) {
596+
if (value !== undefined) {
597+
merged[key] = value;
598+
}
599+
}
600+
return merged as ToolUIPart | DynamicToolUIPart;
601+
}

src/react/useUIMessages.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type { StreamQuery } from "./types.js";
2222
import { type UIMessage, type UIStatus } from "../UIMessages.js";
2323
import { sorted } from "../shared.js";
2424
import { useStreamingUIMessages } from "./useStreamingUIMessages.js";
25+
import { combineUIMessages } from "../deltas.js";
2526

2627
export type UIMessageLike = {
2728
order: number;
@@ -155,9 +156,11 @@ export function useUIMessages<
155156
);
156157

157158
const merged = useMemo(() => {
159+
// Messages may have been split by pagination. Re-combine them here.
160+
const combined = combineUIMessages(sorted(paginated.results));
158161
return {
159162
...paginated,
160-
results: dedupeMessages(paginated.results, streamMessages ?? []),
163+
results: dedupeMessages(combined, streamMessages ?? []),
161164
};
162165
}, [paginated, streamMessages]);
163166

0 commit comments

Comments
 (0)