mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-09 17:12:01 +00:00
fix(frontend): preserve chronological order of thread history after context compression (#3354)
* fix(frontend): preserve chronological order of thread history after context compression Iterate runs from newest to match backend `list_by_thread` (newest-first) and the prepend semantics of the history loader, so refreshed history renders in A→B→C→D→E→F order. Fixes #3352 * fix(frontend): auto-continue loading runs with no visible messages after context compression
This commit is contained in:
parent
8fca56cf43
commit
9a53f9dfbb
@ -106,11 +106,11 @@ function dedupeMessagesByIdentity(messages: Message[]): Message[] {
|
||||
});
|
||||
}
|
||||
|
||||
function findLatestUnloadedRunIndex(
|
||||
export function findLatestUnloadedRunIndex(
|
||||
runs: Run[],
|
||||
loadedRunIds: ReadonlySet<string>,
|
||||
): number {
|
||||
for (let i = runs.length - 1; i >= 0; i--) {
|
||||
for (let i = 0; i < runs.length; i++) {
|
||||
const run = runs[i];
|
||||
if (run && !loadedRunIds.has(run.run_id)) {
|
||||
return i;
|
||||
@ -119,6 +119,19 @@ function findLatestUnloadedRunIndex(
|
||||
return -1;
|
||||
}
|
||||
|
||||
export const MAX_CONSECUTIVE_EMPTY_RUN_LOADS = 5;
|
||||
|
||||
export function shouldAutoContinueOnEmptyRun(
|
||||
fetchedMessageCount: number,
|
||||
consecutiveEmptyLoads: number,
|
||||
maxConsecutiveEmptyLoads: number = MAX_CONSECUTIVE_EMPTY_RUN_LOADS,
|
||||
): boolean {
|
||||
return (
|
||||
fetchedMessageCount === 0 &&
|
||||
consecutiveEmptyLoads < maxConsecutiveEmptyLoads
|
||||
);
|
||||
}
|
||||
|
||||
type RunMessagesPageResponse = {
|
||||
data: RunMessage[];
|
||||
has_more?: boolean;
|
||||
@ -874,6 +887,7 @@ export function useThreadHistory(threadId: string) {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
let consecutiveEmptyLoads = 0;
|
||||
do {
|
||||
pendingLoadRef.current = false;
|
||||
|
||||
@ -927,6 +941,17 @@ export function useThreadHistory(threadId: string) {
|
||||
} else {
|
||||
runBeforeSeqRef.current.delete(run.run_id);
|
||||
loadedRunIdsRef.current.add(run.run_id);
|
||||
if (
|
||||
shouldAutoContinueOnEmptyRun(
|
||||
_messages.length,
|
||||
consecutiveEmptyLoads,
|
||||
)
|
||||
) {
|
||||
consecutiveEmptyLoads += 1;
|
||||
pendingLoadRef.current = true;
|
||||
} else {
|
||||
consecutiveEmptyLoads = 0;
|
||||
}
|
||||
}
|
||||
indexRef.current = findLatestUnloadedRunIndex(
|
||||
runsRef.current,
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { Message, Run } from "@langchain/langgraph-sdk";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
buildRunMessagesUrl,
|
||||
findLatestUnloadedRunIndex,
|
||||
getNextRunMessagesBeforeSeq,
|
||||
getOldestRunMessageSeq,
|
||||
getSummarizationMiddlewareMessages,
|
||||
getVisibleOptimisticMessages,
|
||||
MAX_CONSECUTIVE_EMPTY_RUN_LOADS,
|
||||
mergeMessages,
|
||||
runMessagesPageHasMore,
|
||||
shouldAutoContinueOnEmptyRun,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { RunMessage } from "@/core/threads/types";
|
||||
|
||||
@ -325,3 +328,161 @@ test("buildRunMessagesUrl returns a relative URL when using the nginx proxy", ()
|
||||
"/api/threads/thread-1/runs/run-1/messages?before_seq=42",
|
||||
);
|
||||
});
|
||||
|
||||
test("findLatestUnloadedRunIndex loads the newest run first from a newest-first list", () => {
|
||||
const runs = [
|
||||
{ run_id: "R6" },
|
||||
{ run_id: "R5" },
|
||||
{ run_id: "R4" },
|
||||
{ run_id: "R3" },
|
||||
{ run_id: "R2" },
|
||||
{ run_id: "R1" },
|
||||
] as unknown as Run[];
|
||||
expect(findLatestUnloadedRunIndex(runs, new Set())).toBe(0);
|
||||
});
|
||||
|
||||
test("findLatestUnloadedRunIndex skips already-loaded runs and returns the next newest unloaded run", () => {
|
||||
const runs = [
|
||||
{ run_id: "R6" },
|
||||
{ run_id: "R5" },
|
||||
{ run_id: "R4" },
|
||||
] as unknown as Run[];
|
||||
expect(findLatestUnloadedRunIndex(runs, new Set(["R6"]))).toBe(1);
|
||||
});
|
||||
|
||||
test("findLatestUnloadedRunIndex returns -1 when every run is already loaded", () => {
|
||||
const runs = [{ run_id: "R2" }, { run_id: "R1" }] as unknown as Run[];
|
||||
expect(findLatestUnloadedRunIndex(runs, new Set(["R1", "R2"]))).toBe(-1);
|
||||
});
|
||||
|
||||
test("loading runs in newest-first order and prepending pages yields chronological messages (regression for #3352)", () => {
|
||||
// Simulate backend list_by_thread returning newest first.
|
||||
const runs = [
|
||||
{ run_id: "R6" },
|
||||
{ run_id: "R5" },
|
||||
{ run_id: "R4" },
|
||||
{ run_id: "R3" },
|
||||
{ run_id: "R2" },
|
||||
{ run_id: "R1" },
|
||||
] as unknown as Run[];
|
||||
const runIdToContent: Record<string, string> = {
|
||||
R1: "A",
|
||||
R2: "B",
|
||||
R3: "C",
|
||||
R4: "D",
|
||||
R5: "E",
|
||||
R6: "F",
|
||||
};
|
||||
|
||||
const loaded = new Set<string>();
|
||||
let messages: Message[] = [];
|
||||
|
||||
while (true) {
|
||||
const index = findLatestUnloadedRunIndex(runs, loaded);
|
||||
if (index === -1) break;
|
||||
const run = runs[index]!;
|
||||
const pageMessages = [
|
||||
{
|
||||
id: run.run_id,
|
||||
type: "human",
|
||||
content: runIdToContent[run.run_id],
|
||||
} as Message,
|
||||
];
|
||||
// Mirror loadMessages: prepend new page to existing messages.
|
||||
messages = [...pageMessages, ...messages];
|
||||
loaded.add(run.run_id);
|
||||
}
|
||||
|
||||
expect(messages.map((m) => m.content)).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
]);
|
||||
});
|
||||
|
||||
test("shouldAutoContinueOnEmptyRun does not continue when the run produced messages", () => {
|
||||
expect(shouldAutoContinueOnEmptyRun(3, 0)).toBe(false);
|
||||
expect(shouldAutoContinueOnEmptyRun(1, 4)).toBe(false);
|
||||
});
|
||||
|
||||
test("shouldAutoContinueOnEmptyRun continues when an empty run is below the safety cap", () => {
|
||||
expect(shouldAutoContinueOnEmptyRun(0, 0)).toBe(true);
|
||||
expect(
|
||||
shouldAutoContinueOnEmptyRun(0, MAX_CONSECUTIVE_EMPTY_RUN_LOADS - 1),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("shouldAutoContinueOnEmptyRun stops once consecutive empty loads reach the cap", () => {
|
||||
expect(shouldAutoContinueOnEmptyRun(0, MAX_CONSECUTIVE_EMPTY_RUN_LOADS)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
shouldAutoContinueOnEmptyRun(0, MAX_CONSECUTIVE_EMPTY_RUN_LOADS + 1),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("shouldAutoContinueOnEmptyRun honors a custom safety cap when provided", () => {
|
||||
expect(shouldAutoContinueOnEmptyRun(0, 0, 1)).toBe(true);
|
||||
expect(shouldAutoContinueOnEmptyRun(0, 1, 1)).toBe(false);
|
||||
});
|
||||
|
||||
test("simulating auto-continue across empty runs skips empty contributions and lands on the next run with content (issue #3352 follow-up)", () => {
|
||||
const runs = [
|
||||
{ run_id: "R6" },
|
||||
{ run_id: "R5" },
|
||||
{ run_id: "R4" },
|
||||
{ run_id: "R3" },
|
||||
{ run_id: "R2" },
|
||||
{ run_id: "R1" },
|
||||
] as unknown as Run[];
|
||||
const runIdToMessages: Record<string, Message[]> = {
|
||||
R6: [{ id: "R6", type: "human", content: "F" } as Message],
|
||||
R5: [{ id: "R5", type: "human", content: "E" } as Message],
|
||||
R4: [],
|
||||
R3: [],
|
||||
R2: [],
|
||||
R1: [{ id: "R1", type: "human", content: "A" } as Message],
|
||||
};
|
||||
|
||||
const loaded = new Set<string>();
|
||||
let messages: Message[] = [];
|
||||
|
||||
loaded.add("R6");
|
||||
loaded.add("R5");
|
||||
messages = [...runIdToMessages.R5!, ...runIdToMessages.R6!];
|
||||
|
||||
let consecutiveEmptyLoads = 0;
|
||||
let visited = 0;
|
||||
const visitedRunIds: string[] = [];
|
||||
while (true) {
|
||||
const index = findLatestUnloadedRunIndex(runs, loaded);
|
||||
if (index === -1) break;
|
||||
const run = runs[index]!;
|
||||
visited += 1;
|
||||
visitedRunIds.push(run.run_id);
|
||||
const pageMessages = runIdToMessages[run.run_id] ?? [];
|
||||
messages = [...pageMessages, ...messages];
|
||||
loaded.add(run.run_id);
|
||||
if (
|
||||
!shouldAutoContinueOnEmptyRun(pageMessages.length, consecutiveEmptyLoads)
|
||||
) {
|
||||
consecutiveEmptyLoads = 0;
|
||||
break;
|
||||
}
|
||||
consecutiveEmptyLoads += 1;
|
||||
}
|
||||
|
||||
expect(visitedRunIds).toEqual(["R4", "R3", "R2", "R1"]);
|
||||
expect(visited).toBe(4);
|
||||
expect(messages.map((m) => m.content)).toEqual(["A", "E", "F"]);
|
||||
});
|
||||
|
||||
test("shouldAutoContinueOnEmptyRun input must use the post-filter visible count, not the raw page size (middleware-only runs should still trigger auto-continue)", () => {
|
||||
const filteredVisibleCount = 0;
|
||||
const rawPageSize = 3; // pretend the raw page had 3 middleware-only entries
|
||||
expect(shouldAutoContinueOnEmptyRun(filteredVisibleCount, 0)).toBe(true);
|
||||
expect(shouldAutoContinueOnEmptyRun(rawPageSize, 0)).toBe(false);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user