import type { Message } from "@langchain/langgraph-sdk";
import { expect, rs, test } from "@rstest/core";
import { InfiniteQueryObserver, QueryClient } from "@tanstack/react-query";
import {
buildThreadMessagesPageUrl,
buildVisibleHistoryMessages,
areOptimisticMessagesConfirmed,
computeSummarizationTransientMessages,
countHumanMessagesExcludingSuperseded,
flattenThreadHistoryPages,
getSummarizationMiddlewareMessages,
getThreadHistoryNextPageParam,
getVisibleOptimisticMessages,
mergeRenderedMessageLedger,
mergeTransientHistoryBridge,
mergeTransientHistoryBridgeOrder,
mergeMessages,
parseThreadMessagesPageResponse,
pruneConfirmedTransientMessages,
reconcileThreadHistoryRows,
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,
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",
seq: 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: "hidden",
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: "hidden",
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("edit replay of the only turn hides the optimistic copy once the server human arrives", () => {
// The runtime re-keys the first user message of a thread, so the persisted
// replacement never matches the optimistic id and only the count can confirm
// it. Masking the superseded turn drops the live count to zero first.
const supersededHuman = {
id: "human-1__user",
type: "human",
content: "introduce Li Bai",
} as Message;
const optimisticHuman = {
id: "replacement-1",
type: "human",
content: "introduce Du Fu",
} as Message;
const serverHuman = {
id: "replacement-1__user",
type: "human",
content: "introduce Du Fu",
} as Message;
const baseline = countHumanMessagesExcludingSuperseded(
[supersededHuman],
["human-1__user", "ai-1"],
);
expect(baseline).toBe(0);
expect(getVisibleOptimisticMessages([optimisticHuman], baseline, 0)).toEqual([
optimisticHuman,
]);
expect(getVisibleOptimisticMessages([optimisticHuman], baseline, 1)).toEqual(
[],
);
expect(mergeMessages([], [serverHuman], [])).toEqual([serverHuman]);
});
test("countHumanMessagesExcludingSuperseded keeps turns the replay does not supersede", () => {
const keptHuman = { id: "human-1", type: "human", content: "one" } as Message;
const supersededHuman = {
id: "human-2",
type: "human",
content: "two",
} as Message;
const ai = { id: "ai-1", type: "ai", content: "answer" } as Message;
expect(
countHumanMessagesExcludingSuperseded(
[keptHuman, ai, supersededHuman],
["human-2", "ai-2"],
),
).toBe(1);
});
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("areOptimisticMessagesConfirmed returns true when server messages contain every optimistic id", () => {
const optimisticHuman = {
id: "replacement-human-1",
type: "human",
content: "edited question",
} as Message;
const serverHuman = {
id: "replacement-human-1",
type: "human",
content: "edited question",
} as Message;
const serverAi = {
id: "replacement-ai-1",
type: "ai",
content: "new answer",
} as Message;
expect(
areOptimisticMessagesConfirmed([optimisticHuman], [serverHuman, serverAi]),
).toBe(true);
});
test("areOptimisticMessagesConfirmed ignores optimistic messages without stable ids", () => {
const optimisticHuman = {
type: "human",
content: "edited question",
} as Message;
expect(areOptimisticMessagesConfirmed([optimisticHuman], [])).toBe(false);
});
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("parseThreadMessagesPageResponse accepts a valid history page", () => {
const response = {
data: [runMessage(1), runMessage(2)],
has_more: true,
next_before_seq: 1,
};
expect(parseThreadMessagesPageResponse(response)).toBe(response);
});
test.each([
["missing", undefined],
["non-numeric", "2"],
["fractional", 2.5],
["unsafe", Number.MAX_SAFE_INTEGER + 1],
])(
"parseThreadMessagesPageResponse rejects a %s row seq",
(_description, seq) => {
expect(() =>
parseThreadMessagesPageResponse({
data: [{ ...runMessage(1), seq }],
has_more: false,
next_before_seq: null,
}),
).toThrow("invalid seq");
},
);
test("parseThreadMessagesPageResponse rejects duplicate row seq values", () => {
expect(() =>
parseThreadMessagesPageResponse({
data: [runMessage(1), runMessage(1)],
has_more: false,
next_before_seq: null,
}),
).toThrow("duplicate seq");
});
test("parseThreadMessagesPageResponse rejects an invalid pagination cursor", () => {
expect(() =>
parseThreadMessagesPageResponse({
data: [runMessage(1)],
has_more: true,
next_before_seq: null,
}),
).toThrow("invalid next_before_seq");
});
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("reconcileThreadHistoryRows retains rows displaced from a moving latest page", () => {
const previousRows = Array.from({ length: 50 }, (_, index) =>
runMessage(index + 1),
);
const currentRows = Array.from({ length: 50 }, (_, index) =>
runMessage(index + 51),
);
expect(
reconcileThreadHistoryRows(previousRows, currentRows, false).map(
(message) => message.seq,
),
).toEqual(Array.from({ length: 100 }, (_, index) => index + 1));
});
test("reconcileThreadHistoryRows refreshes overlapping rows without moving them", () => {
const stale = runMessage(50);
const refreshed = {
...runMessage(50),
content: {
id: "message-50",
type: "ai",
content: "final response",
additional_kwargs: { turn_duration: 704 },
} as Message,
};
const reconciled = reconcileThreadHistoryRows(
[runMessage(49), stale],
[refreshed, runMessage(51)],
false,
);
expect(reconciled.map((message) => message.seq)).toEqual([49, 50, 51]);
expect(reconciled[1]).toBe(refreshed);
});
test("reconcileThreadHistoryRows trusts a complete snapshot and prunes missing rows", () => {
const currentRows = [runMessage(2), runMessage(3)];
expect(
reconcileThreadHistoryRows(
[runMessage(1), ...currentRows],
currentRows,
true,
),
).toEqual(currentRows);
});
test("reconcileThreadHistoryRows preserves existing history when a row seq is invalid", () => {
const previousRows = [runMessage(1), runMessage(2)];
const invalidRow = {
...runMessage(3),
seq: undefined,
} as unknown as RunMessage;
const consoleError = rs
.spyOn(console, "error")
.mockImplementation(() => undefined);
expect(reconcileThreadHistoryRows(previousRows, [invalidRow], false)).toBe(
previousRows,
);
expect(consoleError).toHaveBeenCalledOnce();
consoleError.mockRestore();
});
test("reconcileThreadHistoryRows keeps insertion order on an invalid initial snapshot", () => {
const first = {
...runMessage(1),
seq: undefined,
content: { id: "first", type: "human", content: "first" } as Message,
} as unknown as RunMessage;
const second = {
...runMessage(2),
seq: undefined,
content: { id: "second", type: "ai", content: "second" } as Message,
} as unknown as RunMessage;
const consoleError = rs
.spyOn(console, "error")
.mockImplementation(() => undefined);
const currentRows = [first, second];
expect(reconcileThreadHistoryRows([], currentRows, false)).toBe(currentRows);
expect(consoleError).toHaveBeenCalledOnce();
consoleError.mockRestore();
});
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 = [];
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 = [];
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",
seq: 1,
content: oldHuman,
metadata: { caller: "lead_agent" },
created_at: "2026-06-18T00:00:00Z",
},
{
run_id: "run-old",
seq: 2,
content: oldAi,
metadata: { caller: "lead_agent" },
created_at: "2026-06-18T00:00:01Z",
},
{
run_id: "run-new",
seq: 3,
content: newHuman,
metadata: { caller: "lead_agent" },
created_at: "2026-06-18T00:00:02Z",
},
{
run_id: "run-new",
seq: 4,
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",
seq: 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 restores a prefix that was already rendered", () => {
const protectedPrompt = {
id: "protected-prompt",
type: "human",
content: "/ppt-master 帮我做个ppt",
} as Message;
const intermediateReply = {
id: "intermediate-reply",
type: "ai",
content: "正在生成页面",
} as Message;
const pageAnchor = {
id: "page-anchor",
type: "ai",
content: "继续导出 SVG",
} as Message;
const latestAnswer = {
id: "latest-answer",
type: "ai",
content: "任务仍在执行",
} as Message;
const captured = [protectedPrompt, intermediateReply, pageAnchor];
const bridgeOrder = mergeTransientHistoryBridgeOrder([], captured);
const previouslyRenderedOrder = [
protectedPrompt,
intermediateReply,
pageAnchor,
latestAnswer,
]
.map((message) => `message:${message.id}`)
.filter((identity): identity is string => Boolean(identity));
expect(
resolveTransientHistoryBridge(
[pageAnchor, latestAnswer],
captured,
bridgeOrder,
previouslyRenderedOrder,
).map((message) => message.id),
).toEqual([
"protected-prompt",
"intermediate-reply",
"page-anchor",
"latest-answer",
]);
});
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 rescues rendered processing steps missing from a stale live snapshot", () => {
const processingMessages = Array.from({ length: 10 }, (_, index) => {
const step = index + 1;
return {
id: `processing-${step}`,
type: step % 2 === 0 ? "tool" : "ai",
...(step % 2 === 0 ? { tool_call_id: `call-${step - 1}` } : {}),
content: `step ${step}`,
} as Message;
});
// The SDK has already advanced to the 10-message post-compaction window,
// while the UI's previous committed frame still contains steps 1..10.
const staleLiveSnapshot = processingMessages.slice(4);
const summarizationMessages = [
{
id: "__remove_all__",
type: "remove",
content: "",
} as Message,
...staleLiveSnapshot,
];
expect(
computeSummarizationTransientMessages(
staleLiveSnapshot,
summarizationMessages,
new Set(),
processingMessages,
),
).toEqual(processingMessages.slice(0, 4));
});
test("computeSummarizationTransientMessages rescues steps between a protected input and retained tail", () => {
const protectedInput = {
id: "protected-input",
type: "human",
content: "Run a long sequential research task",
} as Message;
const removedSteps = Array.from({ length: 4 }, (_, index) => ({
id: `protected-window-step-${index + 1}`,
type: "ai",
content: `completed ${index + 1}`,
})) as Message[];
const retainedTail = {
id: "retained-tail",
type: "tool",
tool_call_id: "retained-call",
content: "latest search result",
} as Message;
const renderedMessages = [protectedInput, ...removedSteps, retainedTail];
const retainedWindow = [protectedInput, retainedTail];
expect(
computeSummarizationTransientMessages(
retainedWindow,
[
{
id: "__remove_all__",
type: "remove",
content: "",
} as Message,
...retainedWindow,
],
new Set(),
renderedMessages,
),
).toEqual(removedSteps);
});
test("repeated compaction keeps every previously rendered processing step in order", () => {
const processingMessages = Array.from({ length: 12 }, (_, index) => {
const step = index + 1;
return {
id: `repeat-processing-${step}`,
type: step % 2 === 0 ? "tool" : "ai",
...(step % 2 === 0 ? { tool_call_id: `repeat-call-${step - 1}` } : {}),
content: `step ${step}`,
} as Message;
});
const removeAll = {
id: "__remove_all__",
type: "remove",
content: "",
} as Message;
const firstTail = processingMessages.slice(4, 10);
const firstMoved = computeSummarizationTransientMessages(
firstTail,
[removeAll, ...firstTail],
new Set(),
processingMessages.slice(0, 10),
);
const firstBridge = mergeTransientHistoryBridge([], firstMoved);
const firstMerged = mergeMessages(firstBridge, firstTail, []);
const secondTail = processingMessages.slice(6);
const secondMoved = computeSummarizationTransientMessages(
secondTail,
[removeAll, ...secondTail],
new Set(),
processingMessages,
);
const secondBridge = mergeTransientHistoryBridge(firstBridge, secondMoved);
const secondMerged = mergeMessages(secondBridge, secondTail, []);
expect(firstMerged.map((message) => message.id)).toEqual(
processingMessages.slice(0, 10).map((message) => message.id),
);
expect(secondMerged.map((message) => message.id)).toEqual(
processingMessages.map((message) => message.id),
);
});
test("rendered message ledger survives rolling live windows before repeated compaction", () => {
const processingMessages = Array.from({ length: 23 }, (_, index) => {
const step = index + 1;
return {
id: `rolling-processing-${step}`,
type: "ai",
content: `completed ${step}/26`,
} as Message;
});
const firstVisibleWindow = processingMessages.slice(0, 13);
const secondVisibleWindow = processingMessages.slice(8, 18);
const thirdVisibleWindow = processingMessages.slice(13);
const firstLedger = mergeRenderedMessageLedger([], firstVisibleWindow);
const secondLedger = mergeRenderedMessageLedger(
firstLedger,
secondVisibleWindow,
);
const thirdLedger = mergeRenderedMessageLedger(
secondLedger,
thirdVisibleWindow,
);
expect(thirdLedger.map((message) => message.id)).toEqual(
processingMessages.map((message) => message.id),
);
// A later compaction retains only 19..23. The accumulated display ledger
// must still supply 14..18 (and every older displayed step) to the bridge.
const retainedTail = processingMessages.slice(18);
const moved = computeSummarizationTransientMessages(
retainedTail,
[
{
id: "__remove_all__",
type: "remove",
content: "",
} as Message,
...retainedTail,
],
new Set(),
thirdLedger,
);
expect(moved.map((message) => message.id)).toEqual(
processingMessages.slice(0, 18).map((message) => message.id),
);
});
test("rendered message ledger does not retain explicitly superseded messages", () => {
const retained = {
id: "retained-answer",
type: "ai",
content: "keep me",
} as Message;
const superseded = {
id: "superseded-answer",
type: "ai",
content: "replace me",
} as Message;
expect(
mergeRenderedMessageLedger(
[retained, superseded],
[retained],
new Set([superseded.id!]),
),
).toEqual([retained]);
});
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"]);
});