From bec6277930d6ee73c58156689f1556780724e35d Mon Sep 17 00:00:00 2001 From: Vanzeren <53075619+Vanzeren@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:07:59 +0800 Subject: [PATCH] fix(frontend): restore user message above same-run steps after mid-run reload (#4660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- frontend/src/core/threads/hooks.ts | 123 +++++- .../unit/core/threads/message-merge.test.ts | 355 ++++++++++++++++++ 2 files changed, 476 insertions(+), 2 deletions(-) diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 71c9dccc7..4fefa2eb5 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -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(); + 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, diff --git a/frontend/tests/unit/core/threads/message-merge.test.ts b/frontend/tests/unit/core/threads/message-merge.test.ts index 003ba856c..991b997e1 100644 --- a/frontend/tests/unit/core/threads/message-merge.test.ts +++ b/frontend/tests/unit/core/threads/message-merge.test.ts @@ -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: "context", + 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",