mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-16 09:38:41 +00:00
* fix(frontend): keep human input cards with their turn * fix(frontend): keep human input cards with the correct turn Multi-turn ordering in restoreLocalTurnMessageOrder could place an `ask_clarification` (needYourHelp) card on the wrong side of a newly submitted human message, and after an interrupt/stop it could move the current run's own already-executed steps above the human that started them. - Restore established messages that a live checkpoint tail wove after the new human (displacedBaselineMessages). - Treat cards/messages confirmed only by the REST history page as established-past-turn too (confirmedHistoryIdentities), not as in-flight pending steps (displacedHistoryMessages). - Never displace the CURRENT run's own steps after an interrupt/stop; they belong after the human even once canonical history confirms them (currentTurnRunIds, anchored by the pending human's run_id). Fixes #4889 * fix(frontend): preserve ordering across displaced messages * fix(frontend): close canonical history ordering gaps * fix(frontend): preserve current turn anchor after compaction * fix(frontend): anchor the local turn on the submitted human identity Follow-up to #4892. R2 is reachable through the full hook chain: when the checkpoint baseline covers only the latest turn, the server echo of the submitted human confirms the optimistic copy against the unthrottled SDK state while the ~80ms render snapshot cannot show it yet; the baseline-only anchor scan then promoted an older history-only human into the current turn's anchor and moved established history behind it. - Record a LocalTurnAnchor at dispatch: one client-generated human id is shared by the optimistic display copy and the submitted message, so the server X__user echo confirms the exact identity already on screen. - restoreLocalTurnMessageOrder repairs only when that identity is present in the display; a null anchor (hidden human-input reply, regenerate replay) or a not-yet-rendered identity keeps established history untouched. - Optimistic confirmation now observes the same coalesced render snapshot (identity match first, rendered human-count growth as fallback for runtime-re-keyed first turns) instead of the per-chunk array. - Edit replays adopt the prepare response's replacement identity; the render ledger excludes unconfirmed optimistic copies by identity now that the local input no longer uses an opt- prefix, so a failed send cannot pin a message the server never saw. - Anchor lifecycle matches the previous baseline: kept across finish/stop/error until canonical data takes over, replaced by the next local submit, cleared on send failure, thread switch, and replay gaps. * test(threads): type submit mock calls in local-turn-order dom tests * fix(frontend): bound local turn repair to pre-submit history * fix(frontend): preserve pre-submit bridge ordering --------- Co-authored-by: 肘子香香 <hyh112300@163.com> Co-authored-by: 霍英豪 <huoyinghao250707@credithc.com> Co-authored-by: wangzeren <1004695029@qq.com>
3007 lines
84 KiB
TypeScript
3007 lines
84 KiB
TypeScript
import type { Message } from "@langchain/langgraph-sdk";
|
|
import { expect, rs, test } from "@rstest/core";
|
|
import { InfiniteQueryObserver, QueryClient } from "@tanstack/react-query";
|
|
|
|
import { getMessageRunId } from "@/core/messages/run-duration";
|
|
import {
|
|
buildThreadMessagesPageUrl,
|
|
buildVisibleHistoryMessages,
|
|
areOptimisticMessagesConfirmed,
|
|
computeSummarizationTransientMessages,
|
|
countHumanMessagesExcludingSuperseded,
|
|
flattenThreadHistoryPages,
|
|
getCurrentTurnRunIds,
|
|
getSummarizationMiddlewareMessages,
|
|
getThreadHistoryNextPageParam,
|
|
getVisibleOptimisticMessages,
|
|
mergeRenderedMessageLedger,
|
|
mergeTransientHistoryBridge,
|
|
mergeTransientHistoryBridgeOrder,
|
|
mergeMessages,
|
|
parseThreadMessagesPageResponse,
|
|
pruneConfirmedTransientMessages,
|
|
reconcileThreadHistoryRows,
|
|
removeSetItems,
|
|
resolveThreadTransientHistoryBridge,
|
|
resolveTransientHistoryBridge,
|
|
restoreLocalTurnMessageOrder,
|
|
restoreReconnectedTurnMessageOrder,
|
|
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 keeps a protected early message before the first shared anchor instead of dropping it", () => {
|
|
// #4065 established that an early message rescued by summarization must not
|
|
// be appended to the tail: its canonical position is earlier, and the tail is
|
|
// provably wrong. Suppressing it entirely was the other half of that fix, and
|
|
// it is how a user's own question disappeared from a long thread once the
|
|
// first history page no longer reached back to it (#4666).
|
|
//
|
|
// Both concerns hold at once: the message stays before the first shared
|
|
// anchor (never the tail), which is the one position both the checkpoint and
|
|
// seq-sorted history agree on. The gap to the unloaded pages remains, but a
|
|
// gap is recoverable by paging — a dropped message is not.
|
|
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([protectedEarly, 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",
|
|
// The replacement keeps the trusted feed position alongside the run
|
|
// metadata: dropping deerflow_seq here was defect R3.
|
|
additional_kwargs: { turn_duration: 114, deerflow_seq: 1 },
|
|
},
|
|
]);
|
|
});
|
|
|
|
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("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<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",
|
|
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", additional_kwargs: { deerflow_seq: 3 } },
|
|
{ ...newAi, run_id: "run-new", additional_kwargs: { deerflow_seq: 4 } },
|
|
]);
|
|
});
|
|
|
|
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 replaces a submitted user message with its injected server copy", () => {
|
|
const submittedHuman = {
|
|
id: "request-1",
|
|
type: "human",
|
|
content: "Build a presentation",
|
|
} as Message;
|
|
const injectedSystemReminder = {
|
|
id: "request-1",
|
|
type: "system",
|
|
content: "<system-reminder>today</system-reminder>",
|
|
additional_kwargs: { hide_from_ui: true },
|
|
} as Message;
|
|
const injectedMemory = {
|
|
id: "request-1__memory",
|
|
type: "human",
|
|
content: "<memory>context</memory>",
|
|
additional_kwargs: { hide_from_ui: true },
|
|
} as Message;
|
|
const injectedHuman = {
|
|
id: "request-1__user",
|
|
type: "human",
|
|
content: "Build a presentation",
|
|
name: "user-input",
|
|
} as Message;
|
|
const assistantStep = {
|
|
id: "assistant-step-1",
|
|
type: "ai",
|
|
content: "Reading the presentation skill",
|
|
} as Message;
|
|
|
|
const firstLedger = mergeRenderedMessageLedger([], [submittedHuman]);
|
|
const nextFrame = mergeMessages(
|
|
[submittedHuman],
|
|
[injectedSystemReminder, injectedMemory, injectedHuman, assistantStep],
|
|
[],
|
|
).filter((message) => message.additional_kwargs?.hide_from_ui !== true);
|
|
const nextLedger = mergeRenderedMessageLedger(firstLedger, nextFrame);
|
|
|
|
expect(nextLedger).toEqual([injectedHuman, assistantStep]);
|
|
expect(nextLedger.filter((message) => message.type === "human")).toHaveLength(
|
|
1,
|
|
);
|
|
});
|
|
|
|
test("local turn order keeps early streamed steps behind the user message", () => {
|
|
const previousHuman = {
|
|
id: "previous-human",
|
|
type: "human",
|
|
content: "Previous request",
|
|
} as Message;
|
|
const previousAssistant = {
|
|
id: "previous-assistant",
|
|
type: "ai",
|
|
content: "Previous answer",
|
|
} as Message;
|
|
const earlyAssistantStep = {
|
|
id: "early-assistant-step",
|
|
type: "ai",
|
|
content: "Reading the presentation skill",
|
|
} as Message;
|
|
const optimisticHuman = {
|
|
id: "opt-human-current",
|
|
type: "human",
|
|
content: "Build a presentation",
|
|
} as Message;
|
|
const injectedHuman = {
|
|
id: "current-request__user",
|
|
type: "human",
|
|
content: "Build a presentation",
|
|
} as Message;
|
|
const injectedMemory = {
|
|
id: "current-request__memory",
|
|
type: "human",
|
|
content: "<memory>context</memory>",
|
|
additional_kwargs: { hide_from_ui: true },
|
|
} as Message;
|
|
const laterAssistantStep = {
|
|
id: "later-assistant-step",
|
|
type: "ai",
|
|
content: "Writing the presentation plan",
|
|
} as Message;
|
|
const baselineIdentities = new Set([
|
|
"message:previous-human",
|
|
"message:previous-assistant",
|
|
]);
|
|
|
|
expect(
|
|
restoreLocalTurnMessageOrder(
|
|
[previousHuman, previousAssistant, earlyAssistantStep, optimisticHuman],
|
|
baselineIdentities,
|
|
),
|
|
).toEqual([
|
|
previousHuman,
|
|
previousAssistant,
|
|
optimisticHuman,
|
|
earlyAssistantStep,
|
|
]);
|
|
|
|
expect(
|
|
restoreLocalTurnMessageOrder(
|
|
[
|
|
previousHuman,
|
|
previousAssistant,
|
|
earlyAssistantStep,
|
|
injectedMemory,
|
|
injectedHuman,
|
|
laterAssistantStep,
|
|
],
|
|
baselineIdentities,
|
|
),
|
|
).toEqual([
|
|
previousHuman,
|
|
previousAssistant,
|
|
injectedMemory,
|
|
injectedHuman,
|
|
earlyAssistantStep,
|
|
laterAssistantStep,
|
|
]);
|
|
});
|
|
|
|
test("local turn order keeps an existing clarification card with its original turn", () => {
|
|
const previousHuman = {
|
|
id: "previous-human",
|
|
type: "human",
|
|
content: "Research today's market",
|
|
} as Message;
|
|
const previousAnswer = {
|
|
id: "previous-answer",
|
|
type: "ai",
|
|
content: "Here is the completed report",
|
|
} as Message;
|
|
const clarificationCard = {
|
|
id: "clarification-card",
|
|
type: "tool",
|
|
name: "ask_clarification",
|
|
tool_call_id: "clarification-call",
|
|
content: "Which market should I research?",
|
|
} as Message;
|
|
const currentHuman = {
|
|
id: "current-human",
|
|
type: "human",
|
|
content: "Summarize the report",
|
|
} as Message;
|
|
const currentStep = {
|
|
id: "current-step",
|
|
type: "ai",
|
|
content: "Reading the report",
|
|
} as Message;
|
|
const baselineIdentities = new Set([
|
|
"message:previous-human",
|
|
"message:previous-answer",
|
|
"tool:clarification-call",
|
|
]);
|
|
|
|
// A live checkpoint tail can be woven after the newly persisted human
|
|
// message even though the card was already visible before submission.
|
|
expect(
|
|
restoreLocalTurnMessageOrder(
|
|
[
|
|
previousHuman,
|
|
previousAnswer,
|
|
currentHuman,
|
|
clarificationCard,
|
|
currentStep,
|
|
],
|
|
baselineIdentities,
|
|
),
|
|
).toEqual([
|
|
previousHuman,
|
|
previousAnswer,
|
|
clarificationCard,
|
|
currentHuman,
|
|
currentStep,
|
|
]);
|
|
});
|
|
|
|
test("local turn order preserves relative order across displaced message sources", () => {
|
|
const previousHuman = {
|
|
id: "previous-human",
|
|
type: "human",
|
|
content: "Research today's market",
|
|
} as Message;
|
|
const currentHuman = {
|
|
id: "current-human",
|
|
type: "human",
|
|
content: "Summarize the report",
|
|
} as Message;
|
|
const historyOnlyCard = {
|
|
id: "history-only-card",
|
|
type: "tool",
|
|
name: "ask_clarification",
|
|
tool_call_id: "history-only-call",
|
|
content: "Which market should I research?",
|
|
} as Message;
|
|
const baselineAnswer = {
|
|
id: "baseline-answer",
|
|
type: "ai",
|
|
content: "Here is the completed report",
|
|
} as Message;
|
|
const currentStep = {
|
|
id: "current-step",
|
|
type: "ai",
|
|
content: "Reading the report",
|
|
} as Message;
|
|
|
|
expect(
|
|
restoreLocalTurnMessageOrder(
|
|
[
|
|
previousHuman,
|
|
currentHuman,
|
|
historyOnlyCard,
|
|
baselineAnswer,
|
|
currentStep,
|
|
],
|
|
new Set(["message:previous-human", "message:baseline-answer"]),
|
|
new Set(["tool:history-only-call"]),
|
|
),
|
|
).toEqual([
|
|
previousHuman,
|
|
historyOnlyCard,
|
|
baselineAnswer,
|
|
currentHuman,
|
|
currentStep,
|
|
]);
|
|
});
|
|
|
|
test("current turn run id derivation selects only visible unconfirmed steps", () => {
|
|
const baselineStep = {
|
|
id: "baseline-step",
|
|
type: "ai",
|
|
content: "Previous answer",
|
|
run_id: "run-previous",
|
|
} as Message;
|
|
const currentStep = {
|
|
id: "current-step",
|
|
type: "ai",
|
|
content: "Current progress",
|
|
run_id: "run-current",
|
|
} as Message;
|
|
const runlessStep = {
|
|
id: "runless-step",
|
|
type: "tool",
|
|
content: "Pending metadata",
|
|
tool_call_id: "runless-call",
|
|
} as Message;
|
|
const hiddenStep = {
|
|
id: "hidden-step",
|
|
type: "ai",
|
|
content: "Internal control",
|
|
run_id: "run-hidden",
|
|
additional_kwargs: { hide_from_ui: true },
|
|
} as Message;
|
|
const confirmedHistoryStep = {
|
|
id: "confirmed-history-step",
|
|
type: "tool",
|
|
content: "Previously persisted result",
|
|
tool_call_id: "confirmed-history-call",
|
|
run_id: "run-history",
|
|
} as Message;
|
|
|
|
expect(
|
|
getCurrentTurnRunIds(
|
|
[
|
|
baselineStep,
|
|
currentStep,
|
|
runlessStep,
|
|
hiddenStep,
|
|
confirmedHistoryStep,
|
|
],
|
|
new Set(["message:baseline-step"]),
|
|
new Set(["tool:confirmed-history-call"]),
|
|
),
|
|
).toEqual(new Set(["run-current"]));
|
|
expect(
|
|
getCurrentTurnRunIds([runlessStep], new Set(["message:baseline-step"])),
|
|
).toEqual(new Set());
|
|
expect(getCurrentTurnRunIds([currentStep], null)).toEqual(new Set());
|
|
});
|
|
|
|
test("history-confirmed live cards are not derived as current-run steps", () => {
|
|
const previousHuman = {
|
|
id: "previous-human",
|
|
type: "human",
|
|
content: "Research today's market",
|
|
} as Message;
|
|
const currentHuman = {
|
|
id: "current-human",
|
|
type: "human",
|
|
content: "Summarize the report",
|
|
run_id: "run-current",
|
|
} as Message;
|
|
const oldHistoryCard = {
|
|
id: "old-history-card",
|
|
type: "tool",
|
|
name: "ask_clarification",
|
|
tool_call_id: "old-history-call",
|
|
content: "Which market should I research?",
|
|
run_id: "run-previous",
|
|
} as Message;
|
|
const baselineIdentities = new Set(["message:previous-human"]);
|
|
const historyIdentities = new Set(["tool:old-history-call"]);
|
|
const currentTurnRunIds = getCurrentTurnRunIds(
|
|
[oldHistoryCard],
|
|
baselineIdentities,
|
|
historyIdentities,
|
|
);
|
|
|
|
expect(currentTurnRunIds).toEqual(new Set());
|
|
expect(
|
|
restoreLocalTurnMessageOrder(
|
|
[previousHuman, currentHuman, oldHistoryCard],
|
|
baselineIdentities,
|
|
historyIdentities,
|
|
currentTurnRunIds,
|
|
),
|
|
).toEqual([previousHuman, oldHistoryCard, currentHuman]);
|
|
});
|
|
|
|
test("local turn order repairs a displaced suffix when the human is first", () => {
|
|
const currentHuman = {
|
|
id: "current-human",
|
|
type: "human",
|
|
content: "Summarize the report",
|
|
run_id: "run-current",
|
|
} as Message;
|
|
const oldHistoryCard = {
|
|
id: "old-history-card",
|
|
type: "tool",
|
|
name: "ask_clarification",
|
|
tool_call_id: "old-history-call",
|
|
content: "Which market should I research?",
|
|
run_id: "run-previous",
|
|
} as Message;
|
|
|
|
expect(
|
|
restoreLocalTurnMessageOrder(
|
|
[currentHuman, oldHistoryCard],
|
|
new Set(),
|
|
new Set(["tool:old-history-call"]),
|
|
),
|
|
).toEqual([oldHistoryCard, currentHuman]);
|
|
});
|
|
|
|
test("local turn order anchors on the latest non-baseline human after compaction", () => {
|
|
// A summarized checkpoint may omit an older human that remains in canonical
|
|
// history. The latest non-baseline human is the submitted turn even before
|
|
// the server assigns it an id; choosing the first identifiable one would move
|
|
// established intervening turns around the old human.
|
|
const oldHistoryHuman = {
|
|
id: "old-history-human",
|
|
type: "human",
|
|
content: "An earlier request omitted by the checkpoint",
|
|
} as Message;
|
|
const oldHistoryAnswer = {
|
|
id: "old-history-answer",
|
|
type: "ai",
|
|
content: "An earlier answer",
|
|
} as Message;
|
|
const previousHuman = {
|
|
id: "previous-human",
|
|
type: "human",
|
|
content: "The immediately previous request",
|
|
} as Message;
|
|
const previousAnswer = {
|
|
id: "previous-answer",
|
|
type: "ai",
|
|
content: "The immediately previous answer",
|
|
} as Message;
|
|
const currentHuman = {
|
|
type: "human",
|
|
content: "The newly submitted request",
|
|
} as Message;
|
|
const currentStep = {
|
|
id: "current-step",
|
|
type: "ai",
|
|
content: "Current streamed progress",
|
|
} as Message;
|
|
|
|
expect(
|
|
restoreLocalTurnMessageOrder(
|
|
[
|
|
oldHistoryHuman,
|
|
oldHistoryAnswer,
|
|
previousHuman,
|
|
previousAnswer,
|
|
currentHuman,
|
|
currentStep,
|
|
],
|
|
new Set(["message:previous-human", "message:previous-answer"]),
|
|
new Set(["message:old-history-answer"]),
|
|
),
|
|
).toEqual([
|
|
oldHistoryHuman,
|
|
oldHistoryAnswer,
|
|
previousHuman,
|
|
previousAnswer,
|
|
currentHuman,
|
|
currentStep,
|
|
]);
|
|
});
|
|
|
|
test("history-confirmed current steps still move behind their human", () => {
|
|
const previousHuman = {
|
|
id: "previous-human",
|
|
type: "human",
|
|
content: "Research the robot sector",
|
|
} as Message;
|
|
const currentStep = {
|
|
id: "current-step",
|
|
type: "ai",
|
|
content: "Searching for catalysts",
|
|
run_id: "run-current",
|
|
} as Message;
|
|
const currentHuman = {
|
|
id: "current-human",
|
|
type: "human",
|
|
content: "Continue tracking the robot sector",
|
|
run_id: "run-current",
|
|
} as Message;
|
|
|
|
expect(
|
|
restoreLocalTurnMessageOrder(
|
|
[previousHuman, currentStep, currentHuman],
|
|
new Set(["message:previous-human"]),
|
|
new Set(["message:current-step"]),
|
|
),
|
|
).toEqual([previousHuman, currentHuman, currentStep]);
|
|
});
|
|
|
|
test("local turn order keeps the current run's persisted steps after the new human", () => {
|
|
// After an interrupt/stop, the current turn's already-executed steps are
|
|
// flushed into canonical history. They belong AFTER the new human input even
|
|
// though history now confirms them — treating them as displaced old message
|
|
// would move the current run's own progress above the message that owns it.
|
|
const previousHuman = {
|
|
id: "previous-human",
|
|
type: "human",
|
|
content: "Research the robot sector",
|
|
} as Message;
|
|
const currentHuman = {
|
|
id: "current-human",
|
|
type: "human",
|
|
content: "Continue tracking the robot sector",
|
|
run_id: "run-current",
|
|
} as Message;
|
|
const currentAnsweredStep = {
|
|
id: "current-step",
|
|
type: "ai",
|
|
content: "Searching for catalysts",
|
|
run_id: "run-current",
|
|
} as Message;
|
|
const currentToolStep = {
|
|
id: "current-tool",
|
|
type: "tool",
|
|
content: "results",
|
|
tool_call_id: "call-current",
|
|
run_id: "run-current",
|
|
} as Message;
|
|
const baselineIdentities = new Set(["message:previous-human"]);
|
|
const historyIdentities = new Set([
|
|
"message:previous-human",
|
|
"message:current-human",
|
|
"message:current-step",
|
|
"tool:call-current",
|
|
]);
|
|
|
|
// The human already at its correct position, followed by its own steps.
|
|
expect(
|
|
restoreLocalTurnMessageOrder(
|
|
[previousHuman, currentHuman, currentAnsweredStep, currentToolStep],
|
|
baselineIdentities,
|
|
historyIdentities,
|
|
new Set(["run-current"]),
|
|
),
|
|
).toEqual([
|
|
previousHuman,
|
|
currentHuman,
|
|
currentAnsweredStep,
|
|
currentToolStep,
|
|
]);
|
|
|
|
// An empty explicit set exercises the pending-human run_id anchor and still
|
|
// protects the persisted current-run steps.
|
|
expect(
|
|
restoreLocalTurnMessageOrder(
|
|
[previousHuman, currentHuman, currentAnsweredStep, currentToolStep],
|
|
baselineIdentities,
|
|
historyIdentities,
|
|
new Set(),
|
|
),
|
|
).toEqual([
|
|
previousHuman,
|
|
currentHuman,
|
|
currentAnsweredStep,
|
|
currentToolStep,
|
|
]);
|
|
|
|
// Even when the current turn's human is missing its run_id, explicit
|
|
// current-turn run ids keep its steps in place below the human.
|
|
const humanWithoutRun = {
|
|
id: "current-human",
|
|
type: "human",
|
|
content: "Continue tracking the robot sector",
|
|
} as Message;
|
|
expect(
|
|
restoreLocalTurnMessageOrder(
|
|
[previousHuman, humanWithoutRun, currentAnsweredStep, currentToolStep],
|
|
baselineIdentities,
|
|
historyIdentities,
|
|
new Set(["run-current"]),
|
|
),
|
|
).toEqual([
|
|
previousHuman,
|
|
humanWithoutRun,
|
|
currentAnsweredStep,
|
|
currentToolStep,
|
|
]);
|
|
});
|
|
|
|
test("local turn order restores a clarification card that only canonical history confirmed", () => {
|
|
// The card can reach the merged list through the REST history page without
|
|
// ever entering the live checkpoint `messages` value, so it is not in the
|
|
// pre-submit baseline. It must still be treated as an established-past-turn
|
|
// message (not an in-flight pending step) and stay above the new human.
|
|
const previousHuman = {
|
|
id: "previous-human",
|
|
type: "human",
|
|
content: "Research today's market",
|
|
} as Message;
|
|
const previousAnswer = {
|
|
id: "previous-answer",
|
|
type: "ai",
|
|
content: "Here is the completed report",
|
|
} as Message;
|
|
const clarificationCard = {
|
|
id: "clarification-card",
|
|
type: "tool",
|
|
name: "ask_clarification",
|
|
tool_call_id: "clarification-call",
|
|
content: "Which market should I research?",
|
|
} as Message;
|
|
const currentHuman = {
|
|
id: "current-human",
|
|
type: "human",
|
|
content: "Summarize the report",
|
|
} as Message;
|
|
const currentStep = {
|
|
id: "current-step",
|
|
type: "ai",
|
|
content: "Reading the report",
|
|
} as Message;
|
|
// Baseline captured at submit time: the previous turn is there, but the
|
|
// card has not reached the live checkpoint yet, so it is absent.
|
|
const baselineIdentities = new Set([
|
|
"message:previous-human",
|
|
"message:previous-answer",
|
|
]);
|
|
// Canonical history has already committed the card identity.
|
|
const historyIdentities = new Set([
|
|
"message:previous-human",
|
|
"message:previous-answer",
|
|
"tool:clarification-call",
|
|
]);
|
|
|
|
// The merged list has the card woven after the new human (from history).
|
|
expect(
|
|
restoreLocalTurnMessageOrder(
|
|
[
|
|
previousHuman,
|
|
previousAnswer,
|
|
currentHuman,
|
|
clarificationCard,
|
|
currentStep,
|
|
],
|
|
baselineIdentities,
|
|
historyIdentities,
|
|
),
|
|
).toEqual([
|
|
previousHuman,
|
|
previousAnswer,
|
|
clarificationCard,
|
|
currentHuman,
|
|
currentStep,
|
|
]);
|
|
});
|
|
|
|
test("reconnected turn order moves same-run steps back behind the user message", () => {
|
|
// Reload mid-run: replayed `messages-tuple` steps reach the merged list
|
|
// before the turn's human message (the retained replay buffer may have
|
|
// dropped it). The live-only human is woven before the next shared anchor,
|
|
// leaving same-run steps above the user message they belong to.
|
|
const stepA1 = {
|
|
id: "step-a1",
|
|
type: "ai",
|
|
content: "Searching the web",
|
|
tool_calls: [{ id: "tc-a1", name: "web_search", args: {} }],
|
|
run_id: "run-r",
|
|
} as unknown as Message;
|
|
const stepA2 = {
|
|
id: "step-a2",
|
|
type: "tool",
|
|
content: "search results",
|
|
tool_call_id: "tc-a2",
|
|
run_id: "run-r",
|
|
} as Message;
|
|
const human = {
|
|
id: "human-r",
|
|
type: "human",
|
|
content: "Analyze deerflow",
|
|
} as Message;
|
|
const stepB1 = {
|
|
id: "step-b1",
|
|
type: "ai",
|
|
content: "Reading the source",
|
|
run_id: "run-r",
|
|
} as Message;
|
|
|
|
expect(
|
|
restoreReconnectedTurnMessageOrder([stepA1, stepA2, human, stepB1]),
|
|
).toEqual([human, stepA1, stepA2, stepB1]);
|
|
});
|
|
|
|
test("reconnected turn order leaves a resent turn after an interrupted run untouched", () => {
|
|
// Legit layout: an interrupted earlier run left steps without a final
|
|
// answer, then the user sent a new message. The earlier run's steps must
|
|
// NOT be pulled below the new human message.
|
|
const interruptedStep = {
|
|
id: "step-old",
|
|
type: "ai",
|
|
content: "Interrupted run step",
|
|
run_id: "run-1",
|
|
} as Message;
|
|
const human = {
|
|
id: "human-2",
|
|
type: "human",
|
|
content: "Same question again",
|
|
run_id: "run-2",
|
|
} as Message;
|
|
const newStep = {
|
|
id: "step-new",
|
|
type: "ai",
|
|
content: "New run step",
|
|
run_id: "run-2",
|
|
} as Message;
|
|
|
|
expect(
|
|
restoreReconnectedTurnMessageOrder([interruptedStep, human, newStep]),
|
|
).toEqual([interruptedStep, human, newStep]);
|
|
});
|
|
|
|
test("reconnected turn order keeps interrupted uniform-run history in its original turn", () => {
|
|
// Branch-seeded and mocked feeds can stamp every message with one run_id.
|
|
// Without a terminal answer, that synthetic id cannot prove that the older
|
|
// tool step belongs to the newer human turn.
|
|
const human1 = {
|
|
id: "human-1",
|
|
type: "human",
|
|
content: "First question",
|
|
run_id: "run-x",
|
|
} as Message;
|
|
const interruptedStep = {
|
|
id: "step-1",
|
|
type: "ai",
|
|
content: "Working on the first question",
|
|
tool_calls: [{ id: "tc-1", name: "web_search", args: {} }],
|
|
run_id: "run-x",
|
|
} as unknown as Message;
|
|
const human2 = {
|
|
id: "human-2",
|
|
type: "human",
|
|
content: "Second question",
|
|
run_id: "run-x",
|
|
} as Message;
|
|
const step2 = {
|
|
id: "step-2",
|
|
type: "ai",
|
|
content: "Working on the second question",
|
|
run_id: "run-x",
|
|
} as Message;
|
|
|
|
expect(
|
|
restoreReconnectedTurnMessageOrder([
|
|
human1,
|
|
interruptedStep,
|
|
human2,
|
|
step2,
|
|
]),
|
|
).toEqual([human1, interruptedStep, human2, step2]);
|
|
});
|
|
|
|
test("reconnected turn order only moves steps of the sandwiched run in multi-turn history", () => {
|
|
const human1 = { id: "human-1", type: "human", content: "First" } as Message;
|
|
const step1 = {
|
|
id: "step-1",
|
|
type: "ai",
|
|
content: "First run step",
|
|
run_id: "run-1",
|
|
} as Message;
|
|
const answer1 = {
|
|
id: "answer-1",
|
|
type: "ai",
|
|
content: "First answer",
|
|
run_id: "run-1",
|
|
} as Message;
|
|
const misplacedStep = {
|
|
id: "step-misplaced",
|
|
type: "ai",
|
|
content: "Second run step above the human",
|
|
tool_calls: [{ id: "tc-misplaced", name: "read_file", args: {} }],
|
|
run_id: "run-2",
|
|
} as unknown as Message;
|
|
const human2 = {
|
|
id: "human-2",
|
|
type: "human",
|
|
content: "Second",
|
|
} as Message;
|
|
const step2 = {
|
|
id: "step-2",
|
|
type: "ai",
|
|
content: "Second run step below the human",
|
|
run_id: "run-2",
|
|
} as Message;
|
|
|
|
expect(
|
|
restoreReconnectedTurnMessageOrder([
|
|
human1,
|
|
step1,
|
|
answer1,
|
|
misplacedStep,
|
|
human2,
|
|
step2,
|
|
]),
|
|
).toEqual([human1, step1, answer1, human2, misplacedStep, step2]);
|
|
});
|
|
|
|
test("reconnected turn order moves live-only steps before any history loads", () => {
|
|
// Right after reconnect no history page has landed yet: every live message
|
|
// lacks run_id. A run_id-less step below the human proves the live stream
|
|
// is past turn start, so run_id-less steps above the human belong to the
|
|
// same reconnected run.
|
|
const earlyStep = {
|
|
id: "live-early",
|
|
type: "ai",
|
|
content: "Replayed step",
|
|
tool_calls: [{ id: "tc-early", name: "web_search", args: {} }],
|
|
} as unknown as Message;
|
|
const human = {
|
|
id: "live-human",
|
|
type: "human",
|
|
content: "Question",
|
|
} as Message;
|
|
const laterStep = {
|
|
id: "live-later",
|
|
type: "ai",
|
|
content: "Fresh step",
|
|
} as Message;
|
|
|
|
expect(
|
|
restoreReconnectedTurnMessageOrder([earlyStep, human, laterStep]),
|
|
).toEqual([human, earlyStep, laterStep]);
|
|
});
|
|
|
|
test("reconnected turn order keeps run_id-less steps when no run_id-less step follows the human", () => {
|
|
// A run_id-less step above the human is only attributable to the current
|
|
// run when the run_id-less live stream continues below the human. With
|
|
// every message below the human carrying a (different) run_id, the stray
|
|
// step may belong to an older turn and must stay put.
|
|
const human1 = { id: "human-1", type: "human", content: "First" } as Message;
|
|
const step1 = {
|
|
id: "step-1",
|
|
type: "ai",
|
|
content: "First run step",
|
|
run_id: "run-1",
|
|
} as Message;
|
|
const strayStep = {
|
|
id: "stray",
|
|
type: "ai",
|
|
content: "Ambiguous step",
|
|
} as Message;
|
|
const human2 = {
|
|
id: "human-2",
|
|
type: "human",
|
|
content: "Second",
|
|
run_id: "run-2",
|
|
} as Message;
|
|
const step2 = {
|
|
id: "step-2",
|
|
type: "ai",
|
|
content: "Second run step",
|
|
run_id: "run-2",
|
|
} as Message;
|
|
|
|
expect(
|
|
restoreReconnectedTurnMessageOrder([
|
|
human1,
|
|
step1,
|
|
strayStep,
|
|
human2,
|
|
step2,
|
|
]),
|
|
).toEqual([human1, step1, strayStep, human2, step2]);
|
|
});
|
|
|
|
test("reconnected turn order keeps hidden control messages and older-run orphans in place", () => {
|
|
const orphanStep = {
|
|
id: "orphan",
|
|
type: "ai",
|
|
content: "Pagination orphan from an older run",
|
|
tool_calls: [{ id: "tc-orphan", name: "web_search", args: {} }],
|
|
run_id: "run-0",
|
|
} as unknown as Message;
|
|
const hiddenControl = {
|
|
id: "control",
|
|
type: "human",
|
|
content: "<memory>context</memory>",
|
|
additional_kwargs: { hide_from_ui: true },
|
|
} as Message;
|
|
const misplacedStep = {
|
|
id: "misplaced",
|
|
type: "ai",
|
|
content: "Same-run step",
|
|
tool_calls: [{ id: "tc-misplaced", name: "read_file", args: {} }],
|
|
run_id: "run-r",
|
|
} as unknown as Message;
|
|
const human = {
|
|
id: "human-r",
|
|
type: "human",
|
|
content: "Question",
|
|
} as Message;
|
|
const laterStep = {
|
|
id: "later",
|
|
type: "ai",
|
|
content: "Later same-run step",
|
|
run_id: "run-r",
|
|
} as Message;
|
|
|
|
expect(
|
|
restoreReconnectedTurnMessageOrder([
|
|
orphanStep,
|
|
hiddenControl,
|
|
misplacedStep,
|
|
human,
|
|
laterStep,
|
|
]),
|
|
).toEqual([orphanStep, hiddenControl, human, misplacedStep, laterStep]);
|
|
});
|
|
|
|
test("reconnected turn order is a no-op when the human message already leads its turn", () => {
|
|
const human = {
|
|
id: "human-r",
|
|
type: "human",
|
|
content: "Question",
|
|
} as Message;
|
|
const step = {
|
|
id: "step",
|
|
type: "ai",
|
|
content: "Step",
|
|
run_id: "run-r",
|
|
} as Message;
|
|
|
|
expect(restoreReconnectedTurnMessageOrder([human, step])).toEqual([
|
|
human,
|
|
step,
|
|
]);
|
|
expect(restoreReconnectedTurnMessageOrder([step])).toEqual([step]);
|
|
expect(restoreReconnectedTurnMessageOrder([])).toEqual([]);
|
|
});
|
|
|
|
test("reconnected turn order keeps a completed turn's answer above the next human message", () => {
|
|
// Regression for the branch-thread e2e shape: history feeds where every
|
|
// message shares one run_id (branch-seeded threads, mocked feeds). A
|
|
// terminal answer completes its turn and must never be pulled below the
|
|
// next human message even though the naive same-run check would match.
|
|
const human1 = {
|
|
id: "human-1",
|
|
type: "human",
|
|
content: "First question",
|
|
run_id: "run-x",
|
|
} as Message;
|
|
const answer1 = {
|
|
id: "ai-1",
|
|
type: "ai",
|
|
content: "First answer",
|
|
run_id: "run-x",
|
|
} as Message;
|
|
const human2 = {
|
|
id: "human-2",
|
|
type: "human",
|
|
content: "Second question",
|
|
run_id: "run-x",
|
|
} as Message;
|
|
const intermediate = {
|
|
id: "ai-2",
|
|
type: "ai",
|
|
content: "Intermediate answer",
|
|
run_id: "run-x",
|
|
} as Message;
|
|
const toolCalling = {
|
|
id: "ai-3",
|
|
type: "ai",
|
|
content: "",
|
|
tool_calls: [{ id: "tc-1", name: "write_todos", args: {} }],
|
|
run_id: "run-x",
|
|
} as unknown as Message;
|
|
const toolResult = {
|
|
id: "tool-1",
|
|
type: "tool",
|
|
tool_call_id: "tc-1",
|
|
content: "Todos updated",
|
|
run_id: "run-x",
|
|
} as Message;
|
|
const final = {
|
|
id: "ai-4",
|
|
type: "ai",
|
|
content: "Final answer",
|
|
run_id: "run-x",
|
|
} as Message;
|
|
|
|
expect(
|
|
restoreReconnectedTurnMessageOrder([
|
|
human1,
|
|
answer1,
|
|
human2,
|
|
intermediate,
|
|
toolCalling,
|
|
toolResult,
|
|
final,
|
|
]),
|
|
).toEqual([
|
|
human1,
|
|
answer1,
|
|
human2,
|
|
intermediate,
|
|
toolCalling,
|
|
toolResult,
|
|
final,
|
|
]);
|
|
});
|
|
|
|
test("reconnected turn order treats a content-only streaming text as a boundary", () => {
|
|
// Accepted transient (#4304): a still-streaming text step looks like a
|
|
// terminal answer until its tool call arrives, so it stays above the human
|
|
// until canonical history heals the order. Tool-calling steps after the
|
|
// last such boundary are still restored.
|
|
const streamingText = {
|
|
id: "streaming-text",
|
|
type: "ai",
|
|
content: "Let me analyze this",
|
|
run_id: "run-r",
|
|
} as Message;
|
|
const toolStep = {
|
|
id: "tool-step",
|
|
type: "ai",
|
|
content: "",
|
|
tool_calls: [{ id: "tc-1", name: "read_file", args: {} }],
|
|
run_id: "run-r",
|
|
} as unknown as Message;
|
|
const human = {
|
|
id: "human-r",
|
|
type: "human",
|
|
content: "Question",
|
|
} as Message;
|
|
const laterStep = {
|
|
id: "later",
|
|
type: "ai",
|
|
content: "",
|
|
tool_calls: [{ id: "tc-2", name: "write_file", args: {} }],
|
|
run_id: "run-r",
|
|
} as unknown as Message;
|
|
|
|
expect(
|
|
restoreReconnectedTurnMessageOrder([
|
|
streamingText,
|
|
toolStep,
|
|
human,
|
|
laterStep,
|
|
]),
|
|
).toEqual([streamingText, human, toolStep, laterStep]);
|
|
});
|
|
|
|
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"]);
|
|
});
|
|
|
|
test("a compacted checkpoint's protected user message survives a history page window that misses it (#4666)", () => {
|
|
// Captured from a real two-round long run: once the thread passes the
|
|
// 50-row `/messages/page` window AND context compaction fires, the two
|
|
// sources stop overlapping at the head. History's first page starts
|
|
// mid-run, while the compacted checkpoint still carries the turn's first
|
|
// user message (summarization rescues the dynamic-context triplet).
|
|
//
|
|
// That message is the user's own question. Suppressing it because its
|
|
// canonical position sits in an unloaded page makes it vanish from the
|
|
// transcript entirely — the "用户消息消失" reports in #4666 / #4508 / #4363.
|
|
const reminder = {
|
|
id: "u1",
|
|
type: "system",
|
|
content: "<system-reminder><current_date>…</current_date>",
|
|
additional_kwargs: { hide_from_ui: true, dynamic_context_reminder: true },
|
|
} as unknown as Message;
|
|
const firstUserMessage = {
|
|
id: "u1__user",
|
|
type: "human",
|
|
content: "MARK-FIRST-QUESTION",
|
|
} as Message;
|
|
const recentStep = {
|
|
id: "step-40",
|
|
type: "ai",
|
|
content: "step 40",
|
|
} as Message;
|
|
const laterUserMessage = {
|
|
id: "u2",
|
|
type: "human",
|
|
content: "SECOND-QUESTION",
|
|
} as Message;
|
|
|
|
// First history page: starts mid-run, has_more=true — no first user message.
|
|
const canonicalWindow = [recentStep, laterUserMessage];
|
|
// Compacted checkpoint: reminder + rescued first user message + recent tail.
|
|
const compactedCheckpoint = [reminder, firstUserMessage, recentStep];
|
|
|
|
const merged = mergeMessages(canonicalWindow, compactedCheckpoint, []);
|
|
|
|
expect(merged.map((message) => message.content)).toContain(
|
|
"MARK-FIRST-QUESTION",
|
|
);
|
|
});
|
|
|
|
test("a checkpoint message earlier than the loaded window is placed by its seq (#4666)", () => {
|
|
// With `deerflow_seq` on both sides, placement stops being a guess. Captured
|
|
// shape: after compaction the checkpoint still holds the turn's first user
|
|
// message (seq=2) while the first history page starts at seq=29, so the only
|
|
// anchor available to the old rule sat 25 rows into the window.
|
|
const withSeq = (message: Message, seq: number) =>
|
|
({
|
|
...message,
|
|
additional_kwargs: { ...message.additional_kwargs, deerflow_seq: seq },
|
|
}) as Message;
|
|
|
|
const firstUserMessage = withSeq(
|
|
{
|
|
id: "u1__user",
|
|
type: "human",
|
|
content: "MARK-FIRST-QUESTION",
|
|
} as Message,
|
|
2,
|
|
);
|
|
const windowStep = withSeq(
|
|
{ id: "step-29", type: "ai", content: "…step 29" } as Message,
|
|
29,
|
|
);
|
|
const laterUserMessage = withSeq(
|
|
{ id: "u2", type: "human", content: "SECOND-QUESTION" } as Message,
|
|
41,
|
|
);
|
|
|
|
const anchorStep = withSeq(
|
|
{ id: "step-58", type: "ai", content: "…step 58" } as Message,
|
|
58,
|
|
);
|
|
|
|
// The anchor must sit INSIDE the window, as it does in the captured run
|
|
// (canonical #25 of 50): weaving before the anchor is what puts the message
|
|
// in the middle, and only seq can say it belongs at the head.
|
|
const canonicalWindow = [windowStep, laterUserMessage, anchorStep];
|
|
const compactedCheckpoint = [firstUserMessage, anchorStep];
|
|
|
|
expect(
|
|
mergeMessages(canonicalWindow, compactedCheckpoint, []).map(
|
|
(m) => m.content,
|
|
),
|
|
).toEqual(["MARK-FIRST-QUESTION", "…step 29", "SECOND-QUESTION", "…step 58"]);
|
|
});
|
|
|
|
test("buildVisibleHistoryMessages carries each row's seq onto the message", () => {
|
|
const rows = [
|
|
{
|
|
run_id: "run-1",
|
|
seq: 7,
|
|
content: { id: "m1", type: "human", content: "hi" } as Message,
|
|
metadata: { caller: "" },
|
|
created_at: "2026-08-04T00:00:00Z",
|
|
},
|
|
] as RunMessage[];
|
|
|
|
expect(
|
|
buildVisibleHistoryMessages(rows, new Set())[0]!.additional_kwargs
|
|
?.deerflow_seq,
|
|
).toBe(7);
|
|
});
|
|
|
|
test("a checkpoint message earlier than the loaded window is placed by its seq even when the two sides share no anchor (#4666)", () => {
|
|
// What a user hits by opening an old, already-summarized conversation and
|
|
// sending a new message. The loaded page is the newest rows from BEFORE that
|
|
// turn; the compacted checkpoint holds only the rescued first user message
|
|
// plus steps of the new run, which are not in the feed yet. The two sides
|
|
// therefore share no identity at all and the anchor walk never runs — so the
|
|
// rescued turn was appended after the whole window (measured at row 50 of 50
|
|
// on a reproducing run) even though its seq was known the entire time.
|
|
const withSeq = (message: Message, seq: number) =>
|
|
({
|
|
...message,
|
|
additional_kwargs: { ...message.additional_kwargs, deerflow_seq: seq },
|
|
}) as Message;
|
|
|
|
const rescuedFirstTurn = withSeq(
|
|
{
|
|
id: "u1__user",
|
|
type: "human",
|
|
content: "MARK-FIRST-QUESTION",
|
|
} as Message,
|
|
2,
|
|
);
|
|
const loadedWindow = [
|
|
withSeq(
|
|
{ id: "step-172", type: "ai", content: "…step 172" } as Message,
|
|
172,
|
|
),
|
|
withSeq(
|
|
{ id: "step-174", type: "ai", content: "…step 174" } as Message,
|
|
174,
|
|
),
|
|
];
|
|
// Steps of the run the user just started: still streaming, so no seq yet, and
|
|
// no identity in common with the page on screen.
|
|
const newRunSteps = [
|
|
{ id: "step-new-1", type: "ai", content: "…new step 1" } as Message,
|
|
{ id: "step-new-2", type: "ai", content: "…new step 2" } as Message,
|
|
];
|
|
|
|
expect(
|
|
mergeMessages(loadedWindow, [rescuedFirstTurn, ...newRunSteps], []).map(
|
|
(m) => m.content,
|
|
),
|
|
).toEqual([
|
|
"MARK-FIRST-QUESTION",
|
|
"…step 172",
|
|
"…step 174",
|
|
"…new step 1",
|
|
"…new step 2",
|
|
]);
|
|
});
|
|
|
|
test("local turn order anchors on the exact submitted human identity (X__user normalized)", () => {
|
|
// The anchor recorded at submit time names one identity; the server's
|
|
// visible copy arrives as `<id>__user` and normalizes onto it.
|
|
const previousHuman = {
|
|
id: "previous-human",
|
|
type: "human",
|
|
content: "Research the robot sector",
|
|
} as Message;
|
|
const previousAnswer = {
|
|
id: "previous-answer",
|
|
type: "ai",
|
|
content: "Done",
|
|
} as Message;
|
|
const earlyStep = {
|
|
id: "early-step",
|
|
type: "ai",
|
|
content: "Searching for catalysts",
|
|
run_id: "run-new",
|
|
} as Message;
|
|
const serverHumanCopy = {
|
|
id: "new-human__user",
|
|
type: "human",
|
|
content: "Continue tracking",
|
|
run_id: "run-new",
|
|
} as Message;
|
|
|
|
expect(
|
|
restoreLocalTurnMessageOrder(
|
|
[previousHuman, previousAnswer, earlyStep, serverHumanCopy],
|
|
new Set(["message:previous-human", "message:previous-answer"]),
|
|
new Set(["message:previous-human", "message:previous-answer"]),
|
|
new Set(["run-new"]),
|
|
"message:new-human",
|
|
),
|
|
).toEqual([previousHuman, previousAnswer, serverHumanCopy, earlyStep]);
|
|
});
|
|
|
|
test("local turn order keeps established history while the anchored human is absent", () => {
|
|
// R2: the checkpoint baseline covers only the latest turn while canonical
|
|
// history holds an older one. Until the submitted human reaches the render
|
|
// snapshot, no reordering may happen — a history-only human outside the
|
|
// baseline is not proof of the current turn.
|
|
const earlierAnswer = {
|
|
id: "earlier-answer",
|
|
type: "ai",
|
|
content: "Earlier answer",
|
|
} as Message;
|
|
const oldHuman = {
|
|
id: "old-human",
|
|
type: "human",
|
|
content: "An older request",
|
|
} as Message;
|
|
const recentHuman = {
|
|
id: "recent-human",
|
|
type: "human",
|
|
content: "The recent request",
|
|
} as Message;
|
|
const recentAnswer = {
|
|
id: "recent-answer",
|
|
type: "ai",
|
|
content: "The recent answer",
|
|
} as Message;
|
|
const newStep = {
|
|
id: "new-step",
|
|
type: "ai",
|
|
content: "Working on the follow-up",
|
|
run_id: "run-new",
|
|
} as Message;
|
|
const display = [earlierAnswer, oldHuman, recentHuman, recentAnswer, newStep];
|
|
const baseline = new Set(["message:recent-human", "message:recent-answer"]);
|
|
const confirmed = new Set([
|
|
"message:earlier-answer",
|
|
"message:old-human",
|
|
"message:recent-human",
|
|
"message:recent-answer",
|
|
]);
|
|
|
|
expect(
|
|
restoreLocalTurnMessageOrder(
|
|
display,
|
|
baseline,
|
|
confirmed,
|
|
new Set(["run-new"]),
|
|
"message:new-human",
|
|
),
|
|
).toEqual(display);
|
|
});
|
|
|
|
test("local turn order with a null anchor never borrows a history-only human", () => {
|
|
// Hidden human-input replies and regenerate replays submit no visible
|
|
// human, so no human identity may anchor the repair at all.
|
|
const earlierAnswer = {
|
|
id: "earlier-answer",
|
|
type: "ai",
|
|
content: "Earlier answer",
|
|
} as Message;
|
|
const oldHuman = {
|
|
id: "old-human",
|
|
type: "human",
|
|
content: "An older request",
|
|
} as Message;
|
|
const recentHuman = {
|
|
id: "recent-human",
|
|
type: "human",
|
|
content: "The recent request",
|
|
} as Message;
|
|
const replyStep = {
|
|
id: "reply-step",
|
|
type: "ai",
|
|
content: "Applying the answer",
|
|
run_id: "run-reply",
|
|
} as Message;
|
|
const display = [earlierAnswer, oldHuman, recentHuman, replyStep];
|
|
|
|
expect(
|
|
restoreLocalTurnMessageOrder(
|
|
display,
|
|
new Set(["message:recent-human"]),
|
|
new Set(["message:earlier-answer", "message:old-human"]),
|
|
new Set(["run-reply"]),
|
|
null,
|
|
),
|
|
).toEqual(display);
|
|
});
|
|
|
|
test("local turn order repair is idempotent across repeated deliveries", () => {
|
|
const previousHuman = {
|
|
id: "previous-human",
|
|
type: "human",
|
|
content: "Research the robot sector",
|
|
} as Message;
|
|
const earlyStep = {
|
|
id: "early-step",
|
|
type: "ai",
|
|
content: "Searching for catalysts",
|
|
run_id: "run-new",
|
|
} as Message;
|
|
const submittedHuman = {
|
|
id: "local-human-1",
|
|
type: "human",
|
|
content: "Continue tracking",
|
|
} as Message;
|
|
const displaced = [previousHuman, earlyStep, submittedHuman];
|
|
const baseline = new Set(["message:previous-human"]);
|
|
const args = [
|
|
baseline,
|
|
new Set(["message:previous-human"]),
|
|
new Set(["run-new"]),
|
|
"message:local-human-1",
|
|
] as const;
|
|
|
|
const once = restoreLocalTurnMessageOrder(displaced, ...args);
|
|
expect(once).toEqual([previousHuman, submittedHuman, earlyStep]);
|
|
// Re-merging the same repaired snapshot converges to the identical order.
|
|
expect(restoreLocalTurnMessageOrder(once, ...args)).toEqual(once);
|
|
expect(restoreLocalTurnMessageOrder(displaced, ...args)).toEqual(once);
|
|
});
|
|
|
|
test("mergeMessages preserves canonical seq when a live copy without seq replaces the content (R3)", () => {
|
|
// Content replacement must not drop trusted ordering metadata: the live
|
|
// checkpoint copy refreshes the text, but the thread-global position the
|
|
// feed established stays attached to the merged message.
|
|
const history = buildVisibleHistoryMessages(
|
|
[
|
|
{
|
|
run_id: "run-1",
|
|
seq: 1,
|
|
content: { id: "h1", type: "human", content: "question" } as Message,
|
|
metadata: { caller: "lead_agent" },
|
|
created_at: "2026-09-08T00:00:00Z",
|
|
},
|
|
{
|
|
run_id: "run-1",
|
|
seq: 2,
|
|
content: { id: "a1", type: "ai", content: "draft" } as Message,
|
|
metadata: { caller: "lead_agent" },
|
|
created_at: "2026-09-08T00:00:01Z",
|
|
},
|
|
],
|
|
new Set(),
|
|
);
|
|
const live = [
|
|
{ id: "h1", type: "human", content: "question" } as Message,
|
|
{ id: "a1", type: "ai", content: "final answer" } as Message,
|
|
];
|
|
|
|
const merged = mergeMessages(history, live, []);
|
|
|
|
expect(merged.map((message) => message.content)).toEqual([
|
|
"question",
|
|
"final answer",
|
|
]);
|
|
expect(
|
|
merged.map((message) => message.additional_kwargs?.deerflow_seq),
|
|
).toEqual([1, 2]);
|
|
expect(merged.map((message) => getMessageRunId(message))).toEqual([
|
|
"run-1",
|
|
"run-1",
|
|
]);
|
|
});
|
|
|
|
test("mergeMessages places a live message with seq inside the loaded window by position (R4)", () => {
|
|
// Identity anchors alone weave a pending live message before the NEXT shared
|
|
// anchor, inverting positions the server already settled: history
|
|
// H-first(1), A-second(3), A-last(5) plus live H-second(2), A-last(5) came
|
|
// out as 1,3,2,5. The trusted seq skeleton must produce 1,2,3,5.
|
|
const withSeq = (message: Message, seq: number) =>
|
|
({
|
|
...message,
|
|
additional_kwargs: { ...message.additional_kwargs, deerflow_seq: seq },
|
|
}) as Message;
|
|
|
|
const history = [
|
|
withSeq({ id: "h-first", type: "human", content: "first" } as Message, 1),
|
|
withSeq({ id: "a-second", type: "ai", content: "second" } as Message, 3),
|
|
withSeq({ id: "a-last", type: "ai", content: "last" } as Message, 5),
|
|
];
|
|
const live = [
|
|
withSeq(
|
|
{ id: "h-second", type: "human", content: "second q" } as Message,
|
|
2,
|
|
),
|
|
withSeq({ id: "a-last", type: "ai", content: "last" } as Message, 5),
|
|
];
|
|
|
|
const merged = mergeMessages(history, live, []);
|
|
|
|
expect(
|
|
merged.map((message) => message.additional_kwargs?.deerflow_seq),
|
|
).toEqual([1, 2, 3, 5]);
|
|
});
|