mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-07 21:38:53 +00:00
fix(frontend): restore user message above same-run steps after mid-run reload (#4660)
* fix(frontend): restore user message above same-run steps after mid-run reload After a mid-run page reload, replayed messages-tuple steps can reach the merged list before the turn's human message (the retained replay buffer may have dropped it), and the live-only human is woven in before the next shared history anchor. The steps panel then renders above the user message it belongs to and the per-turn token row splits one run in two. restoreLocalTurnMessageOrder only covers the locally submitted turn, so add restoreReconnectedTurnMessageOrder for the reconnected path: a visible AI/tool step above the last visible human is provably misplaced when the same run also appears below the human (canonical history is seq-sorted), or when the step is run_id-less (live-only) and a run_id-less step follows the human. Pagination orphans from older turns (#4399) and resent turns after an interrupted run fail both checks and keep their position. * fix(frontend): never move a completed turn's terminal answer below the next human Branch-seeded threads and mocked feeds can share one run_id across turns, so the same-run sandwich check alone misfired on completed multi-turn history: a previous turn's final answer was pulled below the next user message, stripping the turn of its branchable assistant group (e2e branch-thread.spec.ts). Only steps after the last terminal assistant answer (visible content, no tool calls) in the segment are now candidates for reordering. A still-streaming text step can look like a terminal answer until its tool call arrives (#4304); it then stays above the human until canonical history heals the order — an accepted transient, far safer than moving a completed answer.
This commit is contained in:
parent
11bb8ddcd9
commit
bec6277930
@ -19,7 +19,11 @@ import { fetch } from "../api/fetcher";
|
||||
import { getBackendBaseURL } from "../config";
|
||||
import { useI18n } from "../i18n/hooks";
|
||||
import { getMessageRunId } from "../messages/run-duration";
|
||||
import { isHiddenFromUIMessage } from "../messages/utils";
|
||||
import {
|
||||
hasContent,
|
||||
hasToolCalls,
|
||||
isHiddenFromUIMessage,
|
||||
} from "../messages/utils";
|
||||
import type { FileInMessage } from "../messages/utils";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import { isSidecarThread, SIDECAR_METADATA_KEY } from "../sidecar/thread";
|
||||
@ -645,6 +649,121 @@ export function restoreLocalTurnMessageOrder(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect/reload counterpart of {@link restoreLocalTurnMessageOrder}.
|
||||
*
|
||||
* After a mid-run page reload the local-turn baseline is empty, so the local
|
||||
* restore never runs — yet the same ordering race still applies: replayed
|
||||
* `messages-tuple` steps can reach the merged list before the turn's human
|
||||
* message (the retained replay buffer may even have dropped it), and the
|
||||
* live-only human is then woven in before the next shared history anchor,
|
||||
* leaving steps of the SAME run above the user message they belong to.
|
||||
*
|
||||
* Canonical history is seq-sorted, so a visible AI/tool step sitting above
|
||||
* the last visible human while another message of the same run sits below it
|
||||
* (a "same-run sandwich") is provably misplaced. Run_id-less steps are
|
||||
* live-only (history rows always carry run_id) and are attributable to the
|
||||
* reconnected run only when a run_id-less step also follows the human.
|
||||
*
|
||||
* Only steps after the last terminal assistant answer (visible content, no
|
||||
* tool calls) in the segment are candidates: such an answer completes the
|
||||
* turn that owns it, so everything up to it belongs to a finished turn and
|
||||
* must keep its position even when run_ids are absent or uniform across
|
||||
* turns (branch-seeded history, mocked feeds). A still-streaming text step
|
||||
* can look like a terminal answer before its tool call arrives (#4304); it
|
||||
* then stays above the human until canonical history heals the order — an
|
||||
* accepted transient, far safer than pulling a completed turn's answer
|
||||
* below the next user message. A resent turn after an interrupted run and
|
||||
* pagination orphans from older turns (#4399) fail the checks as well and
|
||||
* are left untouched.
|
||||
*/
|
||||
export function restoreReconnectedTurnMessageOrder(
|
||||
messages: Message[],
|
||||
): Message[] {
|
||||
let humanIndex = -1;
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (message?.type === "human" && !isHiddenFromUIMessage(message)) {
|
||||
humanIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (humanIndex <= 0) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Only the segment since the previous visible human can belong to the
|
||||
// active turn; older turns are anchored by their own human message.
|
||||
let segmentStart = 0;
|
||||
for (let index = humanIndex - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (message?.type === "human" && !isHiddenFromUIMessage(message)) {
|
||||
segmentStart = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (segmentStart >= humanIndex) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
// A terminal assistant answer completes the turn that owns it. Only steps
|
||||
// after the last such boundary can belong to the active turn.
|
||||
let candidateStart = segmentStart;
|
||||
for (let index = segmentStart; index < humanIndex; index++) {
|
||||
const message = messages[index]!;
|
||||
if (
|
||||
message.type === "ai" &&
|
||||
!isHiddenFromUIMessage(message) &&
|
||||
hasContent(message) &&
|
||||
!hasToolCalls(message)
|
||||
) {
|
||||
candidateStart = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const runIdsAfter = new Set<string>();
|
||||
let hasLiveOnlyStepAfter = false;
|
||||
for (const message of messages.slice(humanIndex + 1)) {
|
||||
const runId = getMessageRunId(message);
|
||||
if (runId) {
|
||||
runIdsAfter.add(runId);
|
||||
} else if (
|
||||
(message.type === "ai" || message.type === "tool") &&
|
||||
!isHiddenFromUIMessage(message)
|
||||
) {
|
||||
hasLiveOnlyStepAfter = true;
|
||||
}
|
||||
}
|
||||
|
||||
const misplacedSteps: Message[] = [];
|
||||
const stablePrefix = messages.slice(0, candidateStart);
|
||||
for (const message of messages.slice(candidateStart, humanIndex)) {
|
||||
const isVisibleStep =
|
||||
(message.type === "ai" || message.type === "tool") &&
|
||||
!isHiddenFromUIMessage(message);
|
||||
const runId = getMessageRunId(message);
|
||||
if (
|
||||
isVisibleStep &&
|
||||
((runId !== undefined && runIdsAfter.has(runId)) ||
|
||||
(runId === undefined && hasLiveOnlyStepAfter))
|
||||
) {
|
||||
misplacedSteps.push(message);
|
||||
} else {
|
||||
stablePrefix.push(message);
|
||||
}
|
||||
}
|
||||
if (misplacedSteps.length === 0) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
return [
|
||||
...stablePrefix,
|
||||
messages[humanIndex]!,
|
||||
...misplacedSteps,
|
||||
...messages.slice(humanIndex + 1),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a run-scoped ledger of every visible message that reached a committed
|
||||
* UI frame. Live checkpoint windows can roll forward between two
|
||||
@ -2419,7 +2538,7 @@ export function useThreadStream({
|
||||
);
|
||||
const localTurnOrderBaseline = localTurnOrderBaselineIdentitiesRef.current;
|
||||
return localTurnOrderBaseline === null
|
||||
? merged
|
||||
? restoreReconnectedTurnMessageOrder(merged)
|
||||
: restoreLocalTurnMessageOrder(merged, localTurnOrderBaseline);
|
||||
}, [
|
||||
previouslyRenderedOrder,
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
resolveThreadTransientHistoryBridge,
|
||||
resolveTransientHistoryBridge,
|
||||
restoreLocalTurnMessageOrder,
|
||||
restoreReconnectedTurnMessageOrder,
|
||||
type ThreadMessagesPageResponse,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { RunMessage } from "@/core/threads/types";
|
||||
@ -1623,6 +1624,360 @@ test("local turn order keeps early streamed steps behind the user message", () =
|
||||
]);
|
||||
});
|
||||
|
||||
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 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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user