deer-flow/frontend/tests/unit/core/threads/message-merge.test.ts
Aari f090f01806
perf(frontend): coalesce streaming renders to a frame budget instead of per chunk (#4425)
* perf(frontend): coalesce streaming renders to a frame budget instead of per chunk

While a run streams, the merge/group/render pipeline consumed every SSE chunk
as its own React update (~60/s), re-rendering the whole thread tree per token.
Enable the SDK's same-tick batching (throttle: true) and publish the
render-facing messages snapshot at most once per 80 ms with a leading edge and
a trailing flush, keyed through a memoized merge so identities stay stable
between flushes. Lifecycle consumers (optimistic clearing, summarization
capture, usage baselines) keep reading the per-chunk array.

* perf(frontend): keep the transient bridge order array identity stable

mergeTransientHistoryBridgeOrder cloned unconditionally, so the render-time
call handed the coalesced merge memo a fresh array identity on every render
while the transient history bridge was open, re-running mergeMessages between
flushes. Clone lazily and return the input order when nothing is appended; the
merge only ever appends, so an unchanged length means unchanged content.

Consumers only read the returned order, so reusing the input is safe.

* perf(frontend): drive the render coalescer from a monotonic clock

The coalescing interval was measured with Date.now(). A backward wall-clock
step (NTP correction, sleep/wake) turns the elapsed term negative, so the
scheduled delay becomes interval + jump and the rendered snapshot stalls for
the length of the jump. Read performance.now() once per effect invocation
instead; the timer callback re-reads it because timers fire late and the next
interval must start from the real flush.

Seed the last-flush marker with -Infinity so the first update of a stream
still takes the leading edge under a page-load-relative clock.

* perf(frontend): reset the coalescer flush baseline when a stream ends

The leading-edge flush was scoped to the hook instance rather than to each
stream: a run starting within one interval of the previous one found a recent
flush baseline and deferred its first frame. Drop the baseline when leaving
the streaming state so every stream opens on the leading edge.

* perf(frontend): disarm the trailing flush when the leading edge wins

decideCoalesce checks the elapsed interval before the pending-timer flag, so
an update arriving past the interval takes the leading edge while a trailing
timer is still armed. Timers fire late under main-thread load -- exactly the
regime this coalescer targets -- so that timer then publishes a second time
and slips the flush baseline forward, breaking the at-most-one-flush-per-
interval property when it matters most.

Disarm the pending timer in the flush-now branch, and cover the previously
untested elapsed >= interval && hasPendingTimer quadrant.

* perf(frontend): stop syncing the render snapshot while idle

The snapshot is only read while streaming, so keeping it current on every
idle messages change costs one wasted render per history refetch or thread
navigation. Dropping that publish outright is not safe either: the leading
edge runs in a passive effect, but the render where isStreaming flips true
paints first and returns the snapshot, so a stale one would be painted --
after a thread switch, another thread's messages, since the chat page
deliberately avoids re-mounting on navigation.

Make the snapshot nullable, where null means no snapshot belongs to the
current stream, and return the live array while it is null. The idle branch
then writes state once per stream end instead of once per idle update, and
the stale-frame window does not exist rather than being short.
2026-07-26 08:19:13 +08:00

1158 lines
31 KiB
TypeScript

import type { Message } from "@langchain/langgraph-sdk";
import { expect, rs, test } from "@rstest/core";
import { InfiniteQueryObserver, QueryClient } from "@tanstack/react-query";
import {
buildThreadMessagesPageUrl,
buildVisibleHistoryMessages,
computeSummarizationTransientMessages,
flattenThreadHistoryPages,
getSummarizationMiddlewareMessages,
getThreadHistoryNextPageParam,
getVisibleOptimisticMessages,
mergeTransientHistoryBridge,
mergeTransientHistoryBridgeOrder,
mergeMessages,
pruneConfirmedTransientMessages,
removeSetItems,
resolveThreadTransientHistoryBridge,
resolveTransientHistoryBridge,
type ThreadMessagesPageResponse,
} from "@/core/threads/hooks";
import type { RunMessage } from "@/core/threads/types";
function runMessage(seq?: number): RunMessage {
return {
run_id: "run-1",
...(seq === undefined ? {} : { seq }),
content: {} as Message,
metadata: { caller: "" },
created_at: "2026-05-22T00:00:00Z",
};
}
test("mergeMessages removes duplicate messages already present in history", () => {
const human = {
id: "human-1",
type: "human",
content: "Design an agent",
} as Message;
const ai = {
id: "ai-1",
type: "ai",
content: "Let's design it.",
} as Message;
expect(mergeMessages([human, ai, human, ai], [], [])).toEqual([human, ai]);
});
test("mergeMessages does not collapse an unloaded gap before the first shared anchor", () => {
const protectedEarly = {
id: "protected-early",
type: "human",
content: "写一个算法PDF",
} as Message;
const latestHuman = {
id: "latest-human",
type: "human",
content: "写一本超级小说",
} as Message;
const latestAi = {
id: "latest-ai",
type: "ai",
content: "latest answer",
} as Message;
expect(
mergeMessages([latestHuman, latestAi], [protectedEarly, latestHuman], []),
).toEqual([latestHuman, latestAi]);
});
test("mergeMessages lets live thread messages replace overlapping history", () => {
const oldHuman = {
id: "human-1",
type: "human",
content: "old",
} as Message;
const liveHuman = {
id: "human-1",
type: "human",
content: "live",
} as Message;
const oldAi = {
id: "ai-1",
type: "ai",
content: "old",
} as Message;
const liveAi = {
id: "ai-1",
type: "ai",
content: "live",
} as Message;
expect(mergeMessages([oldHuman, oldAi], [liveHuman, liveAi], [])).toEqual([
liveHuman,
liveAi,
]);
});
test("mergeMessages preserves historical run metadata on a live checkpoint replacement", () => {
const persistedAi = {
id: "ai-1",
type: "ai",
content: "persisted",
additional_kwargs: { turn_duration: 114 },
} as Message;
const history = buildVisibleHistoryMessages(
[
{
run_id: "run-1",
content: persistedAi,
metadata: { caller: "lead_agent" },
created_at: "2026-07-21T00:00:00Z",
},
],
new Set(),
);
const checkpointAi = {
id: "ai-1",
type: "ai",
content: "live checkpoint",
} as Message;
expect(mergeMessages(history, [checkpointAi], [])).toEqual([
{
...checkpointAi,
run_id: "run-1",
additional_kwargs: { turn_duration: 114 },
},
]);
});
test("mergeMessages keeps a protected pre-compression input at its canonical position", () => {
const canonicalInput = {
id: "input-1",
type: "human",
content: "写一个算法PDF",
} as Message;
const checkpointInput = {
id: "input-1",
type: "human",
content: [{ type: "text", text: "写一个算法PDF" }],
} as Message;
const clarificationCard = {
id: "clarification-card",
type: "tool",
tool_call_id: "clarification-call",
content: "Create a new PDF",
} as Message;
const directionAnswer = {
id: "input-3",
type: "human",
content: "二叉树相关的即可",
} as Message;
const canonicalRetainedTail = {
id: "retained-ai",
type: "ai",
content: "persisted tail",
} as Message;
const checkpointRetainedTail = {
id: "retained-ai",
type: "ai",
content: "live tail",
} as Message;
expect(
mergeMessages(
[
canonicalInput,
clarificationCard,
directionAnswer,
canonicalRetainedTail,
],
[checkpointInput, checkpointRetainedTail],
[],
),
).toEqual([
checkpointInput,
clarificationCard,
directionAnswer,
checkpointRetainedTail,
]);
});
test("mergeMessages keeps source order when history and live tail do not overlap", () => {
const historyAi = {
id: "history-ai",
type: "ai",
content: "persisted",
} as Message;
const liveHuman = {
id: "live-human",
type: "human",
content: "live",
} as Message;
expect(mergeMessages([historyAi], [liveHuman], [])).toEqual([
historyAi,
liveHuman,
]);
});
test("mergeMessages appends a trailing live-only segment after newer canonical rows", () => {
const message = (id: string) =>
({ id, type: "human", content: id }) as Message;
const [a, b, c, d, y] = ["a", "b", "c", "d", "y"].map(message) as [
Message,
Message,
Message,
Message,
Message,
];
expect(mergeMessages([a, b, c, d], [b, y], [])).toEqual([a, b, c, d, y]);
});
test("mergeMessages keeps live-only messages between shared anchors in place", () => {
const message = (id: string) =>
({ id, type: "human", content: id }) as Message;
const [a, b, c, d, x, y] = ["a", "b", "c", "d", "x", "y"].map(message) as [
Message,
Message,
Message,
Message,
Message,
Message,
];
expect(mergeMessages([a, b, c, d], [b, x, d, y], [])).toEqual([
a,
b,
c,
x,
d,
y,
]);
});
test("mergeMessages deduplicates tool messages by tool_call_id", () => {
const oldTool = {
id: "tool-message-old",
type: "tool",
tool_call_id: "call-1",
content: "old",
} as Message;
const liveTool = {
id: "tool-message-live",
type: "tool",
tool_call_id: "call-1",
content: "live",
} as Message;
expect(mergeMessages([oldTool], [liveTool], [])).toEqual([liveTool]);
});
test("mergeMessages keeps a visible history message when a hidden live message reuses its id", () => {
const historyHuman = {
id: "human-1",
type: "human",
content: "visible user prompt",
} as Message;
const hiddenReminder = {
id: "human-1",
type: "human",
content: "<system-reminder>hidden</system-reminder>",
additional_kwargs: { hide_from_ui: true },
} as Message;
const liveAi = {
id: "ai-1",
type: "ai",
content: "live answer",
} as Message;
expect(mergeMessages([historyHuman], [hiddenReminder, liveAi], [])).toEqual([
historyHuman,
liveAi,
]);
});
test("mergeMessages lets a visible live message replace overlapping hidden history", () => {
const hiddenHistoryHuman = {
id: "human-1",
type: "human",
content: "<system-reminder>hidden</system-reminder>",
additional_kwargs: { hide_from_ui: true },
} as Message;
const liveHuman = {
id: "human-1",
type: "human",
content: "visible user prompt",
} as Message;
expect(mergeMessages([hiddenHistoryHuman], [liveHuman], [])).toEqual([
liveHuman,
]);
});
test("getSummarizationMiddlewareMessages matches DeerFlow summarization update keys", () => {
const removeAll = {
id: "__remove_all__",
type: "remove",
content: "",
} as Message;
const summary = {
id: "summary-1",
type: "human",
name: "summary",
content: "summary",
} as Message;
expect(
getSummarizationMiddlewareMessages({
"DeerFlowSummarizationMiddleware.before_model": {
messages: [removeAll, summary],
},
}),
).toEqual([removeAll, summary]);
});
test("getSummarizationMiddlewareMessages matches base LangChain summarization update keys", () => {
const summary = {
id: "summary-1",
type: "human",
name: "summary",
content: "summary",
} as Message;
expect(
getSummarizationMiddlewareMessages({
"SummarizationMiddleware.before_model": {
messages: [summary],
},
}),
).toEqual([summary]);
});
test("getSummarizationMiddlewareMessages ignores unrelated suffix-sharing update keys", () => {
const summary = {
id: "summary-1",
type: "human",
name: "summary",
content: "summary",
} as Message;
expect(
getSummarizationMiddlewareMessages({
"OtherSummarizationMiddleware.before_model": {
messages: [summary],
},
}),
).toBeUndefined();
});
test("getVisibleOptimisticMessages hides optimistic user input after server human arrives", () => {
const optimisticHuman = {
id: "opt-human-1",
type: "human",
content: "hello",
} as Message;
expect(getVisibleOptimisticMessages([optimisticHuman], 0, 1)).toEqual([]);
});
test("mergeMessages shows server human instead of optimistic duplicate after first response", () => {
const serverHuman = {
id: "server-human-1",
type: "human",
content: "hello",
} as Message;
const optimisticHuman = {
id: "opt-human-1",
type: "human",
content: "hello",
} as Message;
const visibleOptimistic = getVisibleOptimisticMessages(
[optimisticHuman],
0,
1,
);
expect(mergeMessages([], [serverHuman], visibleOptimistic)).toEqual([
serverHuman,
]);
});
test("getVisibleOptimisticMessages keeps optimistic user input until server human arrives", () => {
const optimisticHuman = {
id: "opt-human-1",
type: "human",
content: "hello",
} as Message;
expect(getVisibleOptimisticMessages([optimisticHuman], 0, 0)).toEqual([
optimisticHuman,
]);
});
test("getVisibleOptimisticMessages keeps non-human optimistic status messages", () => {
const optimisticAi = {
id: "opt-ai-1",
type: "ai",
content: "Uploading files...",
} as Message;
expect(getVisibleOptimisticMessages([optimisticAi], 0, 1)).toEqual([
optimisticAi,
]);
});
test("getVisibleOptimisticMessages hides the upload optimistic pair after server human arrives", () => {
const optimisticHuman = {
id: "opt-human-1",
type: "human",
content: "upload this",
} as Message;
const optimisticUploadingAi = {
id: "opt-ai-uploading",
type: "ai",
content: "Uploading files...",
} as Message;
expect(
getVisibleOptimisticMessages(
[optimisticHuman, optimisticUploadingAi],
0,
1,
),
).toEqual([]);
});
test("getVisibleOptimisticMessages hides optimistic user input after later server turns", () => {
const optimisticHuman = {
id: "opt-human-2",
type: "human",
content: "follow up",
} as Message;
expect(getVisibleOptimisticMessages([optimisticHuman], 3, 4)).toEqual([]);
expect(getVisibleOptimisticMessages([optimisticHuman], 3, 3)).toEqual([
optimisticHuman,
]);
});
test("buildThreadMessagesPageUrl encodes the thread and backward cursor", () => {
expect(
buildThreadMessagesPageUrl(
"https://api.example.test/",
"thread/with space",
18,
),
).toBe(
"https://api.example.test/api/threads/thread%2Fwith%20space/messages/page?before_seq=18",
);
});
test("buildThreadMessagesPageUrl omits before_seq for the latest page", () => {
expect(
buildThreadMessagesPageUrl("https://api.example.test", "thread-1"),
).toBe("https://api.example.test/api/threads/thread-1/messages/page");
});
test("buildThreadMessagesPageUrl returns a relative URL behind nginx", () => {
expect(buildThreadMessagesPageUrl("", "thread-1", 42)).toBe(
"/api/threads/thread-1/messages/page?before_seq=42",
);
});
test("flattenThreadHistoryPages prepends backward pages in global seq order", () => {
expect(
flattenThreadHistoryPages([
{
data: [runMessage(5), runMessage(6)],
has_more: true,
next_before_seq: 5,
},
{
data: [runMessage(3), runMessage(4)],
has_more: true,
next_before_seq: 3,
},
{
data: [runMessage(1), runMessage(2)],
has_more: false,
next_before_seq: null,
},
]).map((message) => message.seq),
).toEqual([1, 2, 3, 4, 5, 6]);
});
test("flattenThreadHistoryPages retains backward pages when the latest page refreshes", () => {
const olderPage = {
data: [runMessage(1), runMessage(2)],
has_more: false,
next_before_seq: null,
};
expect(
flattenThreadHistoryPages([
{
data: [runMessage(3), runMessage(4), runMessage(5)],
has_more: true,
next_before_seq: 3,
},
olderPage,
]).map((message) => message.seq),
).toEqual([1, 2, 3, 4, 5]);
});
test("infinite history refetch recalculates older-page cursors from the refreshed newest page", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const queryKey = ["thread-messages", "thread-1"] as const;
const requestedCursors: Array<number | null> = [];
let availableSeqs = Array.from({ length: 9 }, (_, index) => index + 1);
const observer = new InfiniteQueryObserver(queryClient, {
queryKey,
initialPageParam: null as number | null,
queryFn: ({ pageParam }): ThreadMessagesPageResponse => {
requestedCursors.push(pageParam);
const eligible = availableSeqs.filter(
(seq) => pageParam === null || seq < pageParam,
);
const pageSeqs = eligible.slice(-3);
return {
data: pageSeqs.map(runMessage),
has_more: eligible.length > pageSeqs.length,
next_before_seq:
eligible.length > pageSeqs.length ? (pageSeqs[0] ?? null) : null,
};
},
getNextPageParam: getThreadHistoryNextPageParam,
});
const unsubscribe = observer.subscribe(() => undefined);
await observer.refetch();
await observer.fetchNextPage();
expect(requestedCursors).toEqual([null, 7]);
availableSeqs = Array.from({ length: 12 }, (_, index) => index + 1);
requestedCursors.length = 0;
await queryClient.invalidateQueries({ queryKey });
expect(requestedCursors).toEqual([null, 10]);
expect(
observer
.getCurrentResult()
.data?.pages.map((page) => page.data.map((message) => message.seq)),
).toEqual([
[10, 11, 12],
[7, 8, 9],
]);
expect(observer.getCurrentResult().data?.pageParams).toEqual([null, 10]);
unsubscribe();
queryClient.clear();
});
test("infinite history stops and warns when has_more has no cursor", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const requestedCursors: Array<number | null> = [];
const warnSpy = rs.spyOn(console, "warn").mockImplementation(() => ({}));
const observer = new InfiniteQueryObserver(queryClient, {
queryKey: ["thread-messages", "invalid-cursor"],
initialPageParam: null as number | null,
queryFn: ({ pageParam }): ThreadMessagesPageResponse => {
requestedCursors.push(pageParam);
return { data: [], has_more: true, next_before_seq: null };
},
getNextPageParam: getThreadHistoryNextPageParam,
});
const unsubscribe = observer.subscribe(() => undefined);
try {
await observer.refetch();
await observer.fetchNextPage();
expect(requestedCursors).toEqual([null]);
expect(observer.getCurrentResult().hasNextPage).toBe(false);
expect(warnSpy).toHaveBeenCalledWith(
"Thread history returned has_more without next_before_seq; pagination cannot continue.",
);
} finally {
unsubscribe();
warnSpy.mockRestore();
queryClient.clear();
}
});
test("removeSetItems removes pending superseded ids after submit failure", () => {
expect(
removeSetItems(new Set(["run-old", "run-other"]), ["run-old"]),
).toEqual(new Set(["run-other"]));
});
test("buildVisibleHistoryMessages filters superseded runs but keeps regenerated run", () => {
const oldHuman = {
id: "human-1",
type: "human",
content: "question",
} as Message;
const oldAi = {
id: "ai-old",
type: "ai",
content: "old answer",
} as Message;
const newHuman = {
id: "human-1",
type: "human",
content: "question",
} as Message;
const newAi = {
id: "ai-new",
type: "ai",
content: "new answer",
} as Message;
const rows: RunMessage[] = [
{
run_id: "run-old",
content: oldHuman,
metadata: { caller: "lead_agent" },
created_at: "2026-06-18T00:00:00Z",
},
{
run_id: "run-old",
content: oldAi,
metadata: { caller: "lead_agent" },
created_at: "2026-06-18T00:00:01Z",
},
{
run_id: "run-new",
content: newHuman,
metadata: { caller: "lead_agent" },
created_at: "2026-06-18T00:00:02Z",
},
{
run_id: "run-new",
content: newAi,
metadata: { caller: "lead_agent" },
created_at: "2026-06-18T00:00:03Z",
},
];
// run_id is carried onto each content message (#3779) so historical subtask
// cards can fetch their persisted step history on expand.
expect(buildVisibleHistoryMessages(rows, new Set(["run-old"]))).toEqual([
{ ...newHuman, run_id: "run-new" },
{ ...newAi, run_id: "run-new" },
]);
});
test("buildVisibleHistoryMessages attaches run_id to each content message (#3779)", () => {
const rows: RunMessage[] = [
{
run_id: "run-1",
content: { id: "ai-1", type: "ai", content: "answer" } as Message,
metadata: { caller: "lead_agent" },
created_at: "2026-06-26T00:00:00Z",
},
];
const result = buildVisibleHistoryMessages(rows, new Set());
expect((result[0] as { run_id?: string }).run_id).toBe("run-1");
});
// Regression coverage for #3825: after context summarization the backend emits
// RemoveMessage(ALL) + summary + retained, and onUpdateEvent rescues the removed
// messages into a current-stream transient bridge. The bridge fills only the
// journal flush/refetch gap and never mutates canonical history pages.
const summarizationHuman1 = {
id: "human-1",
type: "human",
content: "round 1 question",
} as Message;
const summarizationAi1 = {
id: "ai-1",
type: "ai",
content: "round 1 answer",
} as Message;
const summarizationHuman2 = {
id: "human-2",
type: "human",
content: "round 2 question",
} as Message;
const summarizationAi2 = {
id: "ai-2",
type: "ai",
content: "round 2 answer (retained)",
} as Message;
const summarizationMovedMessages = [
summarizationHuman1,
summarizationAi1,
summarizationHuman2,
];
test("resolveTransientHistoryBridge keeps rescued messages while history state is stale", () => {
const staleHistory: Message[] = [];
expect(
resolveTransientHistoryBridge(staleHistory, summarizationMovedMessages),
).toEqual(summarizationMovedMessages);
});
test("resolveTransientHistoryBridge appends rescued messages after canonical history", () => {
const olderLoadedHuman = {
id: "older-human",
type: "human",
content: "older loaded turn",
} as Message;
expect(
resolveTransientHistoryBridge(
[olderLoadedHuman],
summarizationMovedMessages,
),
).toEqual([olderLoadedHuman, ...summarizationMovedMessages]);
});
test("resolveTransientHistoryBridge does not collapse an unloaded gap before its first canonical anchor", () => {
// Real regression shape from thread 4e81444d-c6ce-471e-93fd-b6ddb18dc938:
// the default history page starts at event seq=35, while the clarification
// conversation lives at seq=2..14. Context compression captured both the
// old turns and a later message that overlaps the canonical page. The old
// turns must stay suppressed until their canonical page loads; otherwise
// the unloaded seq=15..34 gap is visually collapsed before the page anchor.
const clarificationRequest = {
id: "clarification-request",
type: "ai",
content: "Which PDF should I create?",
} as Message;
const clarificationCard = {
id: "clarification-card",
tool_call_id: "clarification-call",
type: "tool",
content: "Create a new algorithm PDF",
} as Message;
const clarificationAnswer = {
id: "clarification-answer",
type: "human",
content: "Create a new algorithm PDF",
} as Message;
const directionQuestion = {
id: "direction-question",
type: "ai",
content: "Which topic?",
} as Message;
const directionAnswer = {
id: "direction-answer",
type: "human",
content: "Binary trees",
} as Message;
const pageAnchor = {
id: "event-seq-35",
type: "tool",
tool_call_id: "event-seq-35-call",
content: "first message on the latest history page",
} as Message;
const latestAnswer = {
id: "event-seq-88",
type: "ai",
content: "latest answer",
} as Message;
const captured = [
summarizationHuman1,
clarificationRequest,
clarificationCard,
clarificationAnswer,
directionQuestion,
directionAnswer,
pageAnchor,
];
const canonical = [pageAnchor, latestAnswer];
const missingAfterCanonicalRefetch = pruneConfirmedTransientMessages(
captured,
canonical,
);
const bridgeOrder = mergeTransientHistoryBridgeOrder([], captured);
expect(
resolveTransientHistoryBridge(
canonical,
missingAfterCanonicalRefetch,
bridgeOrder,
).map((message) => message.id),
).toEqual(["event-seq-35", "event-seq-88"]);
});
test("resolveTransientHistoryBridge does not duplicate once canonical history catches up", () => {
expect(
resolveTransientHistoryBridge(
summarizationMovedMessages,
summarizationMovedMessages,
),
).toEqual(summarizationMovedMessages);
});
test("resolveTransientHistoryBridge returns history unchanged when the bridge is empty", () => {
const history = [summarizationHuman1, summarizationAi1];
expect(resolveTransientHistoryBridge(history, [])).toBe(history);
});
test("resolveThreadTransientHistoryBridge never leaks a bridge across threads", () => {
const canonical = [
{ id: "older-human", type: "human", content: "older" } as Message,
];
expect(
resolveThreadTransientHistoryBridge(
canonical,
summarizationMovedMessages,
"thread-a",
"thread-b",
),
).toBe(canonical);
expect(
resolveThreadTransientHistoryBridge(
canonical,
summarizationMovedMessages,
null,
null,
),
).toBe(canonical);
expect(
resolveThreadTransientHistoryBridge(
canonical,
summarizationMovedMessages,
"thread-a",
"thread-a",
),
).toEqual([canonical[0], ...summarizationMovedMessages]);
});
test("mergeTransientHistoryBridge preserves chronology across repeated compression", () => {
const human3 = {
id: "human-3",
type: "human",
content: "round 3 question",
} as Message;
const firstBridge = mergeTransientHistoryBridge(
[],
[summarizationHuman1, summarizationAi1],
);
const secondBridge = mergeTransientHistoryBridge(firstBridge, [
summarizationAi1,
summarizationHuman2,
human3,
]);
expect(secondBridge.map((message) => message.id)).toEqual([
"human-1",
"ai-1",
"human-2",
"human-3",
]);
});
test("mergeTransientHistoryBridge does not move a protected input recaptured by later compression", () => {
const protectedInput = {
id: "protected-input",
type: "human",
content: "写一个算法PDF",
} as Message;
const clarification = {
id: "clarification",
type: "ai",
content: "Which kind?",
} as Message;
const laterTail = {
id: "later-tail",
type: "ai",
content: "Working on the PDF",
} as Message;
const firstBridge = mergeTransientHistoryBridge(
[],
[protectedInput, clarification],
);
const secondBridge = mergeTransientHistoryBridge(firstBridge, [
{ ...protectedInput, content: [{ type: "text", text: "写一个算法PDF" }] },
laterTail,
]);
expect(secondBridge.map((message) => message.id)).toEqual([
"protected-input",
"clarification",
"later-tail",
]);
expect(secondBridge[0]?.content).toEqual([
{ type: "text", text: "写一个算法PDF" },
]);
});
test("mergeTransientHistoryBridgeOrder retains confirmed overlap as a non-rendering anchor", () => {
const firstOrder = mergeTransientHistoryBridgeOrder(
[],
[summarizationHuman1, summarizationAi1, summarizationHuman2],
);
const secondOrder = mergeTransientHistoryBridgeOrder(firstOrder, [
summarizationHuman2,
summarizationAi2,
]);
expect(secondOrder).toEqual([
"message:human-1",
"message:ai-1",
"message:human-2",
"message:ai-2",
]);
});
test("mergeTransientHistoryBridgeOrder returns the same array when nothing is new", () => {
const order = mergeTransientHistoryBridgeOrder(
[],
[summarizationHuman1, summarizationAi1],
);
// Identity, not just equality: this runs per render while the bridge is
// active and feeds the coalesced render memo (#4409 Phase 1).
expect(mergeTransientHistoryBridgeOrder(order, [summarizationAi1])).toBe(
order,
);
expect(
mergeTransientHistoryBridgeOrder(order, [
summarizationHuman1,
summarizationAi1,
]),
).toBe(order);
expect(mergeTransientHistoryBridgeOrder(order, [])).toBe(order);
expect(
mergeTransientHistoryBridgeOrder(order, [summarizationHuman2]),
).not.toBe(order);
});
test("mergeTransientHistoryBridgeOrder keeps a recaptured protected prefix in place", () => {
const protectedInput = {
id: "protected-input",
type: "human",
content: "first",
} as Message;
const oldTail = {
id: "old-tail",
type: "ai",
content: "old",
} as Message;
const newTail = {
id: "new-tail",
type: "ai",
content: "new",
} as Message;
const firstOrder = mergeTransientHistoryBridgeOrder(
[],
[protectedInput, oldTail],
);
const secondOrder = mergeTransientHistoryBridgeOrder(firstOrder, [
protectedInput,
newTail,
]);
expect(secondOrder).toEqual([
"message:protected-input",
"message:old-tail",
"message:new-tail",
]);
});
test("merge keeps the full conversation across summarization even when visibleHistory lags (regression for #3825)", () => {
// Hidden summary (name === "summary") + the retained latest answer is all the
// live thread carries after RemoveMessage(ALL).
const hiddenSummary = {
id: "summary-1",
type: "human",
name: "summary",
content: "conversation summary",
} as Message;
const postSummaryThread = [hiddenSummary, summarizationAi2];
// The bad render: visibleHistory is still empty, so without the buffer the
// rescued round-1/2 messages exist in neither merge input and are lost.
const effectiveHistory = resolveTransientHistoryBridge(
[],
summarizationMovedMessages,
);
const merged = mergeMessages(effectiveHistory, postSummaryThread, []);
expect(merged.map((m) => m.id)).toEqual([
"human-1",
"ai-1",
"human-2",
"summary-1",
"ai-2",
]);
});
test("pruneConfirmedTransientMessages drops canonical identities but keeps the rest", () => {
// History has caught up on the first two rescued messages only.
expect(
pruneConfirmedTransientMessages(summarizationMovedMessages, [
summarizationHuman1,
summarizationAi1,
]),
).toEqual([summarizationHuman2]);
});
test("pruneConfirmedTransientMessages keeps entries while canonical history is stale", () => {
expect(
pruneConfirmedTransientMessages(summarizationMovedMessages, []),
).toEqual(summarizationMovedMessages);
});
test("resolveTransientHistoryBridge prefers canonical copy over stale transient copy", () => {
// Same identity, but the buffered copy is an older snapshot. The live history
// copy (e.g. the finalized answer) must win — the buffer only fills gaps, it
// must never overwrite a message history already shows.
const staleBuffered = {
id: "ai-1",
type: "ai",
content: "streaming partial",
} as Message;
const liveFinal = {
id: "ai-1",
type: "ai",
content: "finalized answer",
} as Message;
expect(resolveTransientHistoryBridge([liveFinal], [staleBuffered])).toEqual([
liveFinal,
]);
});
test("computeSummarizationTransientMessages captures live turns dropped before the retained boundary", () => {
const removeAll = {
id: "__remove_all__",
type: "remove",
content: "",
} as Message;
const hiddenSummary = {
id: "summary-1",
type: "human",
name: "summary",
content: "conversation summary",
} as Message;
const liveThreadBeforeSummary = [
summarizationHuman1,
summarizationAi1,
summarizationHuman2,
summarizationAi2,
];
// Summarization emits RemoveMessage(ALL) + hidden summary + retained answer.
const summarizationMessages = [removeAll, hiddenSummary, summarizationAi2];
expect(
computeSummarizationTransientMessages(
liveThreadBeforeSummary,
summarizationMessages,
new Set([hiddenSummary.id!]),
),
).toEqual([summarizationHuman1, summarizationAi1, summarizationHuman2]);
});
test("computeSummarizationTransientMessages excludes already-summarized control messages", () => {
const priorSummary = {
id: "summary-0",
type: "human",
name: "summary",
content: "earlier summary",
} as Message;
const liveThreadBeforeSummary = [
priorSummary,
summarizationHuman1,
summarizationAi1,
summarizationAi2,
];
const summarizationMessages = [
{ id: "__remove_all__", type: "remove", content: "" } as Message,
{
id: "summary-1",
type: "human",
name: "summary",
content: "new summary",
} as Message,
summarizationAi2,
];
// priorSummary is in the summarized set, so it must not enter the bridge.
expect(
computeSummarizationTransientMessages(
liveThreadBeforeSummary,
summarizationMessages,
new Set([priorSummary.id!, "summary-1"]),
),
).toEqual([summarizationHuman1, summarizationAi1]);
});
test("full summarization rescue pipeline keeps the conversation when history state lags (regression for #3825)", () => {
// Exercises the whole rescue algorithm the hook runs: derive the moved
// messages, buffer them, then merge against the post-summary thread while the
// canonical run-event page is still stale (empty).
const removeAll = {
id: "__remove_all__",
type: "remove",
content: "",
} as Message;
const hiddenSummary = {
id: "summary-1",
type: "human",
name: "summary",
content: "conversation summary",
} as Message;
const liveThreadBeforeSummary = [
summarizationHuman1,
summarizationAi1,
summarizationHuman2,
summarizationAi2,
];
const summarizationMessages = [removeAll, hiddenSummary, summarizationAi2];
const moved = computeSummarizationTransientMessages(
liveThreadBeforeSummary,
summarizationMessages,
new Set([hiddenSummary.id!]),
);
const staleHistory: Message[] = [];
const postSummaryThread = [hiddenSummary, summarizationAi2];
const merged = mergeMessages(
resolveTransientHistoryBridge(staleHistory, moved),
postSummaryThread,
[],
);
expect(merged.map((m) => m.id)).toEqual([
"human-1",
"ai-1",
"human-2",
"summary-1",
"ai-2",
]);
});
test("refresh reconstructs the same 1-to-6 order from run events without a bridge", () => {
const canonical = Array.from({ length: 6 }, (_, index) => ({
id: `message-${index + 1}`,
type: index % 2 === 0 ? "human" : "ai",
content: String(index + 1),
})) as Message[];
const checkpointTail = canonical.slice(4);
expect(
mergeMessages(canonical, checkpointTail, []).map(
(message) => message.content,
),
).toEqual(["1", "2", "3", "4", "5", "6"]);
});