From 11e6cdd7e7068e4137fb7ba1cb85f0be0817efce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stellar=E9=B1=BC?= <2182712990@qq.com> Date: Sun, 30 Aug 2026 22:19:52 +0800 Subject: [PATCH] fix(frontend): preserve interrupted uniform-run order (#4834) --- frontend/src/core/threads/hooks.ts | 19 ++++++++- .../unit/core/threads/message-merge.test.ts | 40 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 4fefa2eb5..cbe6c6a96 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -675,7 +675,9 @@ export function restoreLocalTurnMessageOrder( * 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. + * are left untouched. When an interrupted turn and the next turn share one + * synthetic run_id, the previous human anchor plus the missing terminal + * answer makes step ownership ambiguous, so that layout is also preserved. */ export function restoreReconnectedTurnMessageOrder( messages: Message[], @@ -721,6 +723,21 @@ export function restoreReconnectedTurnMessageOrder( } } + const previousHumanRunId = + segmentStart > 0 ? getMessageRunId(messages[segmentStart - 1]!) : undefined; + const hasUniformRunIdSincePreviousHuman = + previousHumanRunId !== undefined && + messages + .slice(segmentStart - 1) + .every( + (message) => + isHiddenFromUIMessage(message) || + getMessageRunId(message) === previousHumanRunId, + ); + if (candidateStart === segmentStart && hasUniformRunIdSincePreviousHuman) { + return messages; + } + const runIdsAfter = new Set(); let hasLiveOnlyStepAfter = false; for (const message of messages.slice(humanIndex + 1)) { diff --git a/frontend/tests/unit/core/threads/message-merge.test.ts b/frontend/tests/unit/core/threads/message-merge.test.ts index 991b997e1..27615240c 100644 --- a/frontend/tests/unit/core/threads/message-merge.test.ts +++ b/frontend/tests/unit/core/threads/message-merge.test.ts @@ -1688,6 +1688,46 @@ test("reconnected turn order leaves a resent turn after an interrupted run untou ).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 = {