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:
Vanzeren 2026-08-04 15:07:59 +08:00 committed by GitHub
parent 11bb8ddcd9
commit bec6277930
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 476 additions and 2 deletions

View File

@ -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,

View File

@ -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",